BlogProfile

Next.js + MDXでブログを作った

今までブログは Notion を CMS として使ってたんだけど色々な理由で剥がすことにしました。 主要な理由としては

  • そもそも Notion Blog が全然メンテされてなくて怖い
  • 公式の Notion API がもうすぐ来るので Notion Blog で使う非公式 API が使えなくなりそうで怖い
  • 数週間の単位で Cookie の有効期限が切れて記事が見れなくなる
  • 謎にデプロイがコケまくるのでメンテがめんどくさい
  • 普通に Notion 自体にドキュメント公開機能があるのにブログにする意味があまりない
  • 画像アップしまくってるとすぐ Notion が課金を要求してくる

等が挙げられる。 今現状このブログも Notion Blog ではなく MDX から SSG している。
Notion はどっかのサービスと違ってしっかりとドキュメントをマークダウンでエクスポート出来る機能があるので移行もそこまで手間じゃない。

そもそも MDX とは

マークダウン(.md)の拡張版。拡張子は.mdx。 通常のマークダウンに加えて、中に JSX やスタイルを書く事ができる。 詳しくは以下!

Markdown for the component era | MDX

MDX lets you use JSX in your markdown content. You can import components, such as interactive charts or alerts, and embed them within your content. This makes writing long-form content with components a blast.

mdxjs.com
MDX

サンプルコードはこんな感じ
これは実際の先日の記事のもの。

<!-- best-album-2020.mdx -->

<!-- ここにメタ情報をいれる (frontmatter) -->
---
title: "2020年好きだったアルバムTOP10"
published: true
draft: false
date: 20201213
ogp: "https://titanicrising.jp/images/ogp/best-album-2020.jpg"
tag:
  - Music
---

<!-- jsxをモジュールとしてimport可能 -->
import { MusicLink } from 'src/components/a/MusicLink'

今年ももう大詰め!

そこまでライブも行かなかったし色々忙しかったので例年ほど新譜も追えてなかったけどせっかくブログがあるので…

それでは10位から!

## 10. The 1975 - Notes On A Conditional Form

<!-- マークダウン内で呼び出せる(普通にjsxも書ける) -->
<MusicLink
  imageSrc={require('../images/best-album-2020/noacf.jpg')}
  spotifySrc={'https://open.spotify.com/album/0o5xjCboti8vXhdoUG9LYi?si=I6yWzXl0TfGRJJaJLF42UA'}
  appleMusicSrc={'https://music.apple.com/jp/album/notes-on-a-conditional-form/1473599936'}
  youtubeSrc={'https://youtube.com/playlist?list=OLAK5uy_lGgAKSKiAp1df3seyJhGzfXaoHVX5QjbU'}
/>

スパソニで来日予定だった1975の4枚目。前作の評価が異常に高く期待値もかなり高かったがメディア評価は賛否両論な結果に。

個人的には前作よりハマる曲多くてかなり好きだが22曲は少し多かったかな…?

必要な機能

ざっくりまとめると、

  • メタ情報を簡単に入れられるように(Frontmatter)
  • ファイルの数だけ自動でページ生成
  • AMP 対応
  • 画像の最適化(next-optimized-images + next/image)
  • コードのシンタックスハイライト

これらを満たすように書いていく。

まずは MDX 対応

必要なパッケージをインストールする。

yarn add @mdx-js/loader @next/mdx babel-loader gray-matter

次に.mdx ファイルを扱うために webpack の設定を弄っていく。 ここで同時に Frontmatter の設定もいれる。 まずは適当なディレクトリに webpack に噛ます loader を書いておく。

// src/lib/loader/fm-loader.js
const matter = require('gray-matter')
const stringifyObject = require('stringify-object')

module.exports = async function (src) {
  const callback = this.async()
  const { content, data } = matter(src)
  const code = `export const frontMatter = ${stringifyObject(data)}
${content}`
  return callback(null, code)
}

次に webpack 側。 Next は webpack の設定も良い感じに隠蔽してくれている。実際に自分でチューニングしたい場合は nextconfig を書き換える必要がある。

// next.config.js

// 追加
webpack(config, { webpack }) {
  config.resolve.extensions.push('.mdx')
  config.module.rules.push({
    test: /\.mdx?/,
    use: [
      'babel-loader',
      '@mdx-js/loader',
      // さっきのfm-loader
      path.join(__dirname, './src/lib/loader/fm-loader')
    ]
  })
  return config
}

これで.mdx ファイルを.tsx ファイル内で import 出来るようになった。

Next.js の API 達を使用してファイルから SSG する

まずは特定のディレクトリの MDX ファイル達を読み取ってファイル名とコンテンツを返す関数をそれぞれ作成する。

// src/lib/blog/getMarkdownPosts.ts
import path from 'path'
import fs from 'fs'
import matter from 'gray-matter'

// <root>/posts/docs/配下の.mdxファイルを指定。
const postDir = path.join(process.cwd(), 'posts/docs/')

// 全ての.mdxのファイル名の配列を返す
export const getMarkdownPostsPaths = async () => {
  const postList = fs.readdirSync(postDir).map((path) => path.split(/\.mdx/)[0])
  return postList
}

// 全ての.mdxのメタデータとコンテンツを返す
export const getMarkDownPosts = async () => {
  const pathList = fs.readdirSync(postDir)
  const contentsPromise = pathList.map(async (p) => {
    const fullPath = path.join(postDir, p)
    const filePath = fs.readFileSync(fullPath, 'utf8')
    const { data, content } = matter(filePath)
    const slug = p.split(/\.mdx/)[0]

    return {
      data,
      slug,
      content
    }
  })

  const contents = await Promise.all(contentsPromise)

  return contents
}

そしたらこの関数を pages 配下のダイナミックルーティングファイル内で呼ぶ。
まずは path を getStaticPaths で返してあげる。
後に AMP 対応をするので fallback は blocking を指定してあげるのがミソ!

// src/pages/blog/[post]/index.tsx

// 略
export const getStaticPaths: GetStaticPaths = async () => {
  // さっきの関数
  const mdxPaths = await getMarkdownPostsPaths()
  const paths = mdxPaths.map((path) => ({ params: { post: path } }))

  return {
    paths,
    fallback: 'blocking'
  }
}

次はデータを getStaticProps で返す。
全然関係ないけどこの辺の Next.js が提供している API 名も全部手打ちなのちょい怖いね。
せっかくなので ISR 使っていきましょう。再評価は 10 秒毎で!

// src/pages/blog/[post]/index.tsx

// 略

// .ampがパスに入ってしまうのを弾く雑なスクリプト
const getPostSlug = (ctx: GetStaticPropsContext) => {
  const cutAmp = (slug: string) => {
    if (slug.includes('.amp')) {
      return slug.split('.amp')[0]
    }
    return slug
  }
  if (ctx.params.post instanceof Array) {
    return cutAmp(ctx.params.post[0])
  }
  return cutAmp(ctx.params.post)
}

export const getStaticProps: GetStaticProps<StaticProps> = async (ctx) => {
  const postSlug = getPostSlug(ctx)
  // さっきの関数
  const mdxPosts = await getMarkDownPosts()

  const posts = mdxPosts.map((target) => ({
    title: target.data.title,
    slug: target.slug,
    published: target.data.published || false,
    draft: target.data.draft || false,
    date: target.data.date,
    ogp: target.data.ogp || BASE_URL + '/images/tr.jpg',
    tag: target.data.tag,
    content: target.content
  }))

  const post = posts.find((p) => p.slug === postSlug)

  return {
    props: {
      post,
      posts
    },
    revalidate: 10
  }
}

一覧ページのコードは省略してますが同じ関数を応用して getStaticProps すれば書けます。 詳しくはソースコード参照。
あとは config で hybrid amp を指定してあげれば下準備は完了。
View に落としていく。

// src/pages/blog/[post]/index.tsx

// 略
export const config = {
  amp: 'hybrid'
}

View 部分の作成

肝は AMP 対応をどうするか。
これは Next.js の isAmp フックと@mdx-js/react の提供する MDX Provider を使用して出し分ける事が出来る。

MDX Provider とは

MDX から特定のエレメントを受け取った時に独自のコンポーネントを当てられる Wrapper Component。 以下のように使用する。

import { MDXProvider } from '@mdx-js/react'
import SomeMdx from 'path/to/some.mdx'

const components = {
  a: (props) => <a className="custom-anchor">{props.children}</a>
}

const Component: React.FC = () => {
  return (
    <MDXProvider components={components}>
      <SomeMdx />
    </MDXProvider>
  )
}

上記の場合、import した mdx ファイルにリンクが含まれていたら通常はインラインの a タグが出力されるところで、 "custom-anchor"クラスを付与した a タグを吐かせられる。

これと useAmp フックを応用して通常は next/image、amp 時には amp-img を出す事も可能。(width と height の指定は少し工夫する必要アリ)。

import { MDXProvider } from '@mdx-js/react'
import SomeMdx from 'path/to/some.mdx'
import { useAmp } from 'next/amp'
import Image from 'next/image'

const components = {
  img: (props) => {
    if (isAmp) {
      return (
        <amp-img
          className={props.className}
          src={props.src}
          alt={props.alt}
          width={props.width}
          height={props.height}
          layout={props.layout || 'responsive'}
        />
      )
    }

    return (
      <div className="next-image-container">
        <Image
          className={props.className}
          src={props.src}
          alt={props.alt}
          width={props.width}
          height={props.height}
          loading={props.loading || 'lazy'}
          layout="responsive"
        />
      </div>
    )
  }
}

const Component: React.FC = () => {
  return (
    <MDXProvider components={components}>
      <SomeMdx />
    </MDXProvider>
  )
}

コードの場合はこんな感じ

// 略
const components = {
  code: (props) => {
    const language = props.className.includes('language-')
      ? props.className.split('language-')[1]
      : props.className

    const grammar: Prism.Grammar =
      Prism.languages[language.toLowerCase()] || Prism.languages.tsx

    return (
      <code
        key={props.children}
        data-language={language}
        dangerouslySetInnerHTML={{
          __html: Prism.highlight(String(props.children), grammar, language)
        }}
      />
    )
  }
}

こんな感じで地道に出しわけつつ必要に応じてカスタムコンポーネントを当てていけば完成する。

dynamic import

ページによって読み込む mdx ファイルを指定し分ける為に next/dynamic を使用する。

Optimizing: Lazy Loading | Next.js

Lazy load imported libraries and React Components to improve your application's overall loading performance.

nextjs.org

next/dynamicはトップレベルでの使用が推奨されているので事前に全てのmdxのdynamic importを記述し、 ページ毎にそれぞれimportする必要がある。

以下のように getStaticProps で取得した slug をパスしてあげる。 これを実現するため、next.jsのビルド時に全てのdynamic-importを含むファイルを自動で生成する。 具体的には以下のようなファイルを書いた。

// src/assets/scripts/createMdxImport.ts
import { join } from 'path'
import { getMarkDownPosts } from '../../lib/blog/getMarkdownPosts'
import { writeFile } from '../../lib/fs-helpers'

// Next.js固有のLogger
import * as Log from 'next/dist/build/output/log'

export const createMdxImport = () => {
  // blogのgetStaticPropsと共通の関数
  getMarkDownPosts().then((mdxPosts) => {
    const slugs = mdxPosts.map((post) => post.slug)
    const targetPath = `${join(process.cwd(), 'posts')}/mdx-list.ts`
    const stringifiedImports = slugs
      .map((s) => `'${s}': dynamic(() => import('posts/docs/${s}.mdx'))`)
      .join(',')

    const stringifiedFileContent = `
      import dynamic from 'next/dynamic'
      type MDXPostsType = { [key: string]: React.ComponentType }
      export const MDXPosts: MDXPostsType = {${stringifiedImports}}
    `
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    const formatted = require('prettier').format(stringifiedFileContent, {
      singleQuote: true,
      semi: false,
      trailingComma: 'none',
      parser: 'typescript'
    })

    writeFile(targetPath, formatted)

    Log.info(
      `\u001b[32m[Titanic Rising MDX Processor]: Successfully created import references for ${slugs.length} paths.`
    )
    Log.info(`\u001b[32mSee '${targetPath}' \u001b[32mfor details.\u001b[37m`)
  })
}

next.config.jsはJavaScriptで記述されているので上記のスクリプトを実行するにはts-nodeを使用する必要がある。

'use strict'
require('ts-node').register({
  compilerOptions: {
    module: 'commonjs',
    target: 'esnext'
  },
})
exports.createMdxImport = require('./createMdxImport.ts').createMdxImport

上のmain.jsをnext.coonfig.js上で実行する

// next.config.js

// 追加
const { createMdxImport } = require('./src/assets/scripts/main')
createMdxImport()

const nextConfig = {
  // nextConfig
}

module.exports = nextConfig

この状態でnext、あるいはnext buildコマンドを走らせると以下のようにファイルを生成したぜ!とログが表示される

log

posts/docs上に以下のようなファイルが生成されている

import dynamic from 'next/dynamic'
type MDXPostsType = { [key: string]: React.ComponentType }
export const MDXPosts: MDXPostsType = {
  'about-amp': dynamic(() => import('posts/docs/about-amp.mdx')),
  'about-zenn-dev': dynamic(() => import('posts/docs/about-zenn-dev.mdx')),
  'all-my-friends': dynamic(() => import('posts/docs/all-my-friends.mdx')),
  'best-album-2020': dynamic(() => import('posts/docs/best-album-2020.mdx')),
  'book-smart': dynamic(() => import('posts/docs/book-smart.mdx')),
  'bump-is-good': dynamic(() => import('posts/docs/bump-is-good.mdx')),
  'changed-blog-domain': dynamic(
    () => import('posts/docs/changed-blog-domain.mdx')
  )
  // 略
}

あとはダイナミックルーティング上でこのMDXPostsオブジェクトをインポートし、staticPathを使用するなどして参照すればOK 勿論Ampなどでも問題なく表示される。

import React from 'react'
import { MDXProvider } from '@mdx-js/react'
import dynamic from 'next/dynamic'

// 追加
import { MDXPosts } from 'posts/mdx-list'

const Component: React.FC = (props) => {
  // post.slugにキーが入ってる想定
  const MDX = MDXPosts[post.slug]

  const components = {
    // hogehoge
  }

  return (
    <MDXProvider components={components}>
      <MDX />
    </MDXProvider>
  )
}

工夫したところは以上。 タグとか細かい部分に関してはそのうちまとめる。 途中部分までのソースコードは以下。
自分の名前とかがハードコードされているのでその辺は…

GitHub - Kaisa55275/titanic-rising: Source of titanicrising.jp

Source of titanicrising.jp. Contribute to Kaisa55275/titanic-rising development by creating an account on GitHub.

github.com
GitHub