no7.space
😺

Next.js でブログをつくる:記事詳細ページ

今回は、前回に引き続き、Markdown を使って記事を管理しようと思います。
CMSを使う方法も考えたんですが、ほとんどの CMS が記事データを Mrkdown で返却してくれないんですよね。
基本的にレンダリング済みの HTML なんです。

CMS ごとの特殊記法とかに対応済みなデータが来るという意味ではこれでもありなのかもしれませんが、できれば Markdown で受け取って、
サイト側でそれをパースしたいとおもったので、引き続きファイルベースでいくことにしました。

あとは、自前でヘッドレス CMS っぽい何かを作るってのも考えたんですが、管理画面作り始めるとそれこそいつ完成するかわからなくなっちゃうので、まぁそれは追々かな……

記事の取得

記事は、Markdown で記事を書くときの定番である、Front Matter 付きの Markdown です。
Callouts とかも使いたいので、GitHub が拡張してる GFM を使っていこうと思います。

このサイトは、Next.js の App Router なので、まずは、[slug]/page.tsx を作りました。

タイトルだのなんだの、いろいろと表示したいところですが、まずは本文を出さねばなので、最小限の実装から。

type Props = {
  postType: string
}

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params
  const filePath = path.join(process.cwd(), 'content/blog', `${slug}.md`)
  let fileContent: string

  try {
    fileContent = await fs.readFile(filePath, 'utf-8')
  } catch {
    return notFound()
  }

  const { content, data } = matter(fileContent)
  const fm = data as PostMetadata

  return (
    <MDXRemote
      source={content}
      options={{
        mdxOptions: {
          remarkPlugins: [remarkGfm, remarkGithubAlerts, remarkBreaks],
          rehypePlugins: [
            [
              rehypeShiki,
              {
                theme: 'catppuccin-mocha',
              },
            ],
            [
              rehypeExternalLinks,
              {
                target: '_blank',
                rel: 'noopener noreferrer',
              },
            ],
          ],
        },
      }}
    />
  )
}

Markdown をどうやってページに埋め込むか

Markdown を読み込んで動的にページを作る場合、主に remark / rehype を使って Markdown をパースして、HTMLを生成し、それを適当な div とかに突っ込むというの方法がよくある手法です。
以前のサイトのときもたしかそういうふうにして、あのときは Nuxt だったので、 v-html を使って入れてました。React ならば dangerouslySetInnerHTML ですね。

ただ、これは、dangerouslySetInnerHTML という名前からもわかる通りで、XSS のリスクをもっていたりと危険な要素です。
もちろん、自分しか記事を触らないんだからへーきへーきって考え方もあるかもしれませんが、人間いつどんなミスをするかわかりませんので、可能性は少しでも下げたいところ。

ということで今回は、next-mdx-remote-clientMDXRemote を使って MDX を JSX に変換して表示する方法にしました。

MDX というのは、Markdown に JSX 書けるクレイジーなやつです。こいつを始めてみたときは、はじめて JSX に触れた時以上になんじゃこりゃーって感じでした

なお、MDX といいつつ、MDXRemote は Markdown にも対応してますので、基本的には Markdown のままで使います。

Note

これは、ブログエディタとして VSCode 使ってるのともちょっと関係してきます。いずれこれも記事にしようとおもってます。

remark / rehype 拡張をいれる

MDXRemote も、内部的には remark / rehype らしいので、これらのプラグインも使えます。
今回は以下を導入しました

  • remark plugin
    • remark-gfm
      • GitHub Flavored Markdown という GitHub が拡張した Markdown を使えるようにします
      • Issue や PR で書いてる記法が使えるのでテックブログとの相性もよいです
    • remark-github-alerts
      • GitHub の Issue とかでも使える callout(上記の Note とか)記法が使えるようになるプラグインです
      • 強調ブロックとか作るのに使います
    • remark-breaks
      • Markdown 上の改行(\n ソフト改行)をパースしたときにハード改行(<br>)に変換してくれます
      • これないと通常の Markdown の仕様だと地味に面倒なんですよね。
  • rehype plugin
    • shiki (rehypeShiki)
      • シンタックスハイライト用ライブラリです
      • ちなみに四季じゃなくて式らしいです
    • rehype-external-links
      • 外部リンクに target とか rel の設定ができるようになります

これでとりあえず記事を読み込んで表示できるようになりました。

ただ、動的にファイルを読み込んで処理するのでそのままだとちょっと動作が遅いので、やっぱり SSG しようとおもいます。

メタデータの生成

記事ごとのメタデータを生成して、タイトルとか、OGP とかの設定をおこないます。
これは、generateMetadata を使えば良いみたいです。

import { siteTitle } from '@/site.coinfig.ts'

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const filePath = path.join(process.cwd(), 'content/blog', `${slug}.md`)
  let fileContent: string
  try {
    fileContent = await fs.readFile(filePath, 'utf-8')
  } catch {
    throw notFound()
  }
  const { data } = matter(fileContent)
  const fm = data as PostMetadata
  const pageTitle = [fm.title, siteTitle].filter(Boolean).join(' | ')
  const url = new URL(`/blog/${slug}`, baseUrl)
  return {
    title: pageTitle,
    description: fm.description ?? '',
    openGraph: {
      title: pageTitle,
      description: fm.description ?? '',
      url,
      type: 'article',
    },
    twitter: {
      title: pageTitle,
      description: fm.description ?? '',
      card: 'summary_large_image',
    },
  }
}

SSG できるようにする

generateStaticParams をつかうことで、動的なページでビルド時にルートを生成できるようになるそうです。
動的なルートの場合 []で囲った、変数的なフォルダ名がを使ってますが、ここに当てはまる値のリストをここで生成するってことらしいです。

export async function generateStaticParams() {
  const dir = path.join(process.cwd(), 'content/blog')
  const files = await fs.readdir(dir)
  return files
    .filter((f) => f.endsWith('.md'))
    .map((f) => ({ slug: f.replace(/\.md$/, '') }))
}

今回は、[slug]/page.tsx なんで、パラメータは [slug] となるので、返す値も

{
  slug: .....
}

みたいな形をつくってます

あと、確実に SSR で動作させるため、

export const dynamic = 'force-static'

も忘れずに入れておきます

とりあえずこれで、記事詳細は出せるようになりました。
次は、一覧つくります