今までブログは Notion を CMS として使ってたんだけど色々な理由で剥がすことにしました。 主要な理由としては
- そもそも Notion Blog が全然メンテされてなくて怖い
- 公式の Notion API がもうすぐ来るので Notion Blog で使う非公式 API が使えなくなりそうで怖い
- 数週間の単位で Cookie の有効期限が切れて記事が見れなくなる
- 謎にデプロイがコケまくるのでメンテがめんどくさい
- 普通に Notion 自体にドキュメント公開機能があるのにブログにする意味があまりない
- 画像アップしまくってるとすぐ Notion が課金を要求してくる
等が挙げられる。
今現状このブログも Notion Blog ではなく MDX から SSG している。
Notion はどっかのサービスと違ってしっかりとドキュメントをマークダウンでエクスポート出来る機能があるので移行もそこまで手間じゃない。
そもそも MDX とは
マークダウン(.md)の拡張版。拡張子は.mdx。 通常のマークダウンに加えて、中に JSX やスタイルを書く事ができる。 詳しくは以下!
サンプルコードはこんな感じ
これは実際の先日の記事のもの。
<!-- 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
Dynamically import JavaScript modules and React Components and split your code into manageable chunks.


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コマンドを走らせると以下のようにファイルを生成したぜ!とログが表示される
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>
)
}
工夫したところは以上。
タグとか細かい部分に関してはそのうちまとめる。
途中部分までのソースコードは以下。
自分の名前とかがハードコードされているのでその辺は…