Next.js製マークダウンブログを作ります。プレーンな状態に必要最低限のNode.jsパッケージをインストールし、どのようなディレクトリ構成で、どのファイルに何をコーディングすれば実装できるか、その手順をまとめていきます。
これまでにNext.jsにマークダウンを実装する方法を試行錯誤する過程でmarkdownと名のつく色々なNode.jsパッケージを試しましたが、自分が求める理想的なマークダウンブログの機能は実現できませんでした。中にはmdファイルからHTMLを生成する静的サイト的な実装の例はありましたが、今回はSPA的な実装が目的です。
また、せっかくNext.jsを使っているのだから、next-routesやJSXを最大限活かして洗練された設計にしたいと考えています。
参考文献
Next.jsで自分用のブログを作った話
Nuxt.jsでMarkdownで作成可能なブログを作った
Reactコンポーネントにマークダウンファイルを読み込むにはどうすればよいですか?
達成条件
- SPAとして動作
- 投稿記事をmdファイルで作成
- 記事メタ情報をfrontmatterで管理
- 記事内容(content)をマークダウン記法で執筆
- ページテンプレートでfrontmatterとcontentを取得
- JSXで最小限のファイル構成(HTML・CSS・JS統合)
インストールするパッケージ
-
nextNext.js用 -
reactNext.js用 -
react-domNext.js用 -
raw-loadermdファイル → frontmatter & markdownテキスト 読み込み -
react-markdownmarkdownテキスト → HTML 変換 -
gray-matterfrontmatter → JSON 変換
Next.jsは主にSPA設計用
Next.jsとは、React.jsベースのコーディングで構成されるフレームワーク。JSXでの記法が使えるのでHTMLとJavaScriptを単一のファイルの中に書くことができ、styled-jsxというライブラリによってCSSも単一ファイルの中に書けるため、ファイル同士の依存関係の管理(書き出し・読み込み)の手間が省ける。さらにNext.js標準のnext-routesやAPIルートによってシンプルなファイル構成ながら充実した機能を持つブログシステムを設計できる。
HTML、CSS、JavaScriptを統合できるメリットは、外部ファイルを読み込む必要が無いため、ファイルパスを管理したり、必要なファイルを忘れず読み込んだり、エディタ画面を行き来してコーディングしたり、変更があった場合に双方のファイルを編集したり、といった手間が一切なくなること。しかも統合したCSSはローカルオブジェクトとして扱われるのでシンプルなセレクタ(id・class・タグ名)で書いても他と重複する心配がない。
Next.jsプロジェクトを生成するのに必要なNode.jsパッケージとして、デフォルトでnext react react-domの3つをインストールする。
raw-loaderでmdファイルを読み込み
Next.js製マークダウンブログでは、mdファイル作成という形で記事執筆を行う。直接HTMLとして執筆するわけではないので、何かしらの方法でmdファイルをページテンプレートに読み込まなければならない。その方法としてwebpackのloaderモジュールが有力。
webpackとは、Node.jsによってサーバサイドで動作するモジュールバンドラで(※1)、コード分割や統合によって書き出しや読み込みを可能にするツール。標準ではJavaScriptをバンドル
するものだが、その他のHTMLやCSSや静的ファイルなどもモジュールとなり得ることから(※2)、必要なときに必要なコードやデータを非同期に読み込む機能の実装が可能となる(※3)。
それらの機能を実装するためのパッケージとしてraw-loaderが提供されている。これは外部ファイルを文字列データとして読み込むツール(※4)。これを使って投稿記事のmdファイルをテキストデータに変換し、それをさらにgray-matterやreact-markdownでHTMLやJSONに変換してページテンプレートに挿入することが可能となる。
ここでの調べだけでもwebpackは多くの機能を持っており、今後も使い続けることは間違いない。ただ奥が深すぎて理解が難しく基礎勉強だけでも骨が折れそう。本番環境と開発環境でビルド方法が違うらしく(※3)、まずその辺からして入門しなくてはならない。とりあえずwebpack含めその他Node.jsライブラリ全体での位置付けをフローの図解などで把握する必要がある(※5)。今回のNext.js製マークダウンブログ制作の最難関は間違いなくraw-loaderの実装。
参考文献
raw-loader - GitHub
※1 Webpackってどんなもの?
※2 【React入門10】ローダーでJSファイル以外もバンドルする
※3 【意訳】Webpackの混乱ポイント
※4 webpack の Loader を試してみる #raw-loader
※5 入門者/初心者にもわかるwebpack 4の基礎
Nuxt.jsでMarkdownファイルからFAQページなどを作れるようしてみた(.mdファイル内でコンポーネントも使える)
babel-loader
gray-matterで記事メタ情報を解析
投稿記事のmdファイルのfromtmatterと本文を解析してJSONデータに変換するツール。そのJSONデータをReactコンポーネントにpropsとして渡す。
例えば公式ページのサンプルコードを見ると、frontmatterがJSONに変換される様子が分かる。
const matter = require('gray-matter');
console.log(matter('---\ntitle: Home\n---\nOther stuff'));
//=> { data: { title: 'Home'}, content: 'Other stuff' }
matterメソッドに渡した引数の文字列は、下記のようなfrontmatterを一行で書いただけのもの。frontmatterだけでなく記事本文もJSONに変換してくれている。
---
title: Home
---
Other stuff
あとはこのJSONデータをどうやってページテンプレートの中で読み込むかが問題。
■ReactコンポーネントにJSONデータを反映させるには、Reactコンポーネントのライフサイクルを理解した上でデータを読み込むタイミングを見極めなくてはならない。例えばgetInitialPropsメソッドの利用など。
react-markdownでマークダウン記法をHTMLに変換
マークダウン記法の文字列をpropsとして渡すとHTMLに変換してくれる<ReactMarkdown />コンポーネントが使えるようになるツール。公式のサンプルコードをみると動作が理解できる。
- 必要なモジュールを
requireする - マークダウン記法の文字列
inputを用意 -
<ReactMarkdown>コンポーネントにpropsとして渡す - 書き出し先を
document.getByIdする
const React = require('react')
const ReactDOM = require('react-dom')
const ReactMarkdown = require('react-markdown')
const input = '# This is a header\n\nAnd this is a paragraph'
ReactDOM.render(
<ReactMarkdown source={input} />,
document.getElementById('container')
)
参考文献
react-markdown - npm
必要最低限のパッケージの組み合わせ
gray-matterはmdファイルのfrontmatterと本文をJSONに変換するツール。react-markdownはマークダウン記法をHTMLに変換してDOMに書き出すツール。あとは変換したデータにパスを通してを取得するファイルシステムがあればマークダウンブログは実現できる。
環境構築
ここからは実際にNext.jsマークダウンブログ構築の手順を踏んでいく。最終的なファイル構成を把握した上で、ファイル作成の方法やタイミングと、それぞれの依存関係などを意識しながら読み進めると理解しやすいと思うので、まずは
ファイル構成
- package.json
- package-lock.json
- node_modules/
- components/
- Menu.jsx
- Header.jsx
- Footer.jsx
- Layout.jsx
- pages/
- index.jsx
- about.jsx
- [posts]/
- index.jsx
- post.jsx
- drawing/
- 2020-01-01.md
- 2020-01-02.md
- programming/
- 2020-01-03.md
- 2020-01-04.md
- public/
- images/
- logo.png
- profile.png
- sns/
- twitter.png
- note.png
- github.png
- 2020-01-01-thumb.png
- 2020-01-02-thumb.png
- 2020-01-03-thumb.png
- 2020-01-04-thumb.png
- images/
インストール
まずは必要なNode.jsパッケージを一括インストール。
$ npm init
$ npm install --save next react react-dom react-markdown gray-matter
Next.jsの基本設定
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
Next.jsでHello World
とりあえず動作確認のために必要最低限のコンテンツとしてトップページとなるpages/index.jsxを作成しておく。
export default function Index() {
return(
<h1>Hello World</h1>
);
}
アプリを起動。
$ npm run dev
http://localhost:3000/に接続すると「Hello World」と表示される。
以降は本格的に以下の作業を進めていく。
- Reactコンポーネントの作成
- ページテンプレートの作成
- 動的ルーティングの設定
- マークダウン記法の実装
- 投稿記事リストの取得
Reactコンポーネントの作成
Menuコンポーネントには固定ページへのリンクを配置。トップページ、ブログページ、固定ページをリストアップする。
import Link from 'next/link'
export default function Menu(){
return(
<div>
<Link href="/" className="linkStyle"><a>Home</a></Link>
<ul className="linkStyle">
Blog
<li><Link href="/drawing"><a>Drawing</a></Link></li>
<li><Link href="/programming"><a>Programming</a></Link></li>
</ul>
<Link href="/about" className="linkStyle"><a>About</a></Link>
<style jsx>{`
(中略)
`}</style>
</div>
)
}
各ページでサイト情報を記載するときはpackage.jsonに記述されたデータを使い回す。主にサイト名、キャッチコピー、管理者名あたりは様々な箇所で使うので、変更する可能性を考慮して一つのファイルにまとめて呼び出せるよう一括管理しておく。
ちなみにnpm initするとサイト情報を入力するよう促されるのでそれを再利用すればいい。ただしnameに入力するアプリ名は日本語が対応していっぽいので、独自にtitleというサイト名の項目を追記する必要がある。とりあえず下記の項目があれば十分。
{
"title": "サイト名",
"description": "キャッチコピー",
"author": "名前"
}
Headerコンポーネントでは、サイトロゴ、キャッチコピー、Menuコンポーネントを読み込んでいる。
import Menu from '../components/Menu'
const info = require('../package.json')
export default function Header() {
return(
<div>
<h1>
<img className="logoImage" src="/images/logo.png" alt={info.title} title={info.title} />
</h1>
<p>{info.description}</p>
<Menu/>
<style jsx>{`
(中略)
`}</style>
</div>
);
}
Footerコンポーネントでは、プロフィール、SNSリンク。コピーライトを記載。
const info = require('../package.json')
export default function Footer() {
return(
<div>
<h2>Profile</h2>
<img src="/images/profile.png" alt={info.author} title={info.author} />
<span>{info.author}</span>
<p>自己紹介文</p>
<ul>
<li><a href="https://twitter.com/id" target="_blank"><img src="/images/sns/twitter.png" /></a></li>
<li><a href="https://note.com/id" target="_blank"><img src="/images/sns/note.png" /></a></li>
<li><a href="https://github.com/id" target="_blank"><img src="/images/sns/github.png" /></a></li>
</ul>
<p className="copyright">© 2013 {info.title}</p>
<style jsx>{`
(中略)
`}</style>
</div>
);
}
HeaderとFooterを読み込んで{props.children}を渡せば基本レイアウトのコンポーネントが出来上がる。
import Header from '../components/Header'
import Footer from '../components/Footer'
export default function Layout(props){
return(
<div>
<Header />
{props.children}
<Footer/>
<style global jsx>{`
(中略)
`}</style>
</div>
);
}
トップページ・固定ページの作成
Layoutコンポーネントをベースにトップページを表示。
import Layout from '../components/Layout'
export default function Index() {
return (
<Layout>
<h2>Home</h2>
</Layout>
);
}
固定ページを設置する場合、pages/に固定ページ用のファイルを作成する。コードはpages/index.jsxとほぼ同じで、<Layout />の中身を書き換えるだけで済む。固定ページの内容はHTMLをほぼベタ書きで構わない。
import Layout from '../components/Layout'
export default function Profile() {
return (
<Layout>
<h2>About</h2>
<p>固定ページごとのコンテンツを書く</p>
</Layout>
);
}
ここまででサイトの静的な部分は完成したので、アプリを起動してみる。
$ npm run dev
そしてhttp://localhost:3000/に接続すれば投稿記事リストページ以外は表示される。画像では分からないが、「Blog」にマウスhoverすると「Drawing」と「Programming」が表示される状態。
投稿記事リストの作成
トップページのメニューから「Blog」の子リンクをクリックすると投稿記事リストが表示される。ブログの種類(Drawing・Programming)を分岐はnext-routesで行う。
例えば、/pages/[posts]/index.jsxを投稿記事リストテンプレートにすることで、http://localhost:3000/drawing/post1にアクセスしたときのdrawing/というURLが勝手に生成される。
■また、そのときの{router.query.posts}に格納された値はdrawingになるはずなので、それを使ってdrawing/の中のmdファイルから任意の投稿記事情報を取得して一覧できる。投稿記事のリストアップは、各ディレクトリに入っているmdファイルを配列に格納しておき、mapメソッドで解析して一覧を作成する。ここで言う解析とは、gray-matterでmdファイルのfrontmatterや本文をJSONデータに変換すること。
投稿記事テンプレートの作成
これは個別の投稿記事のページテンプレート。ファイル名を[post].jsxとしておくことで投稿記事別のパーマリンクを割り当てられる。パーマリンクの指定は、投稿記事リストからのリンク先のhref属性に記述して行う。
■このテンプレートではreact-markdownでHTMLに変換するためのマークダウン文字列を取得するためのgray-matterでmdファイルにアクセスする処理を書く必要がある。ここでReactコンポーネントのライフサイクル的に最適なタイミングを指定してmdファイルにアクセスする。
import React, { Component } from 'react'
import ReactMarkdown from 'react-markdown'
import termsFrPath from './Terms.fr.md'
class Terms extends Component {
constructor(props) {
super(props)
this.state = { terms: null }
}
componentWillMount() {
fetch(termsFrPath).then((response) => response.text()).then((text) => {
this.setState({ terms: text })
})
}
// <ReactMarkdown /> にstateを渡してレンダリング
render() {
return (
<div className="content">
<ReactMarkdown source={this.state.terms} />
</div>
)
}
}
export default Terms
記事のメタ情報・内容のmdファイル
そしてこれが「Drawing」ブログの記事情報としてpages/[posts]/drawing/の中に保管されたmdファイルの例。他に「プログラミング」ブログをやるならprogramming/というディレクトリを作ればいいし、「雑記」ブログとしてdiary/を用意してもいい。
今回はブログの種類をディレクトリを分けることで区別したが、実はブログのカテゴリーで分類する方が簡単に済む。しかし、全く違う分野のブログをいくつか設置するつもりでいるので、投稿記事リストで混同させたくなかった。「Next.jsでマークダウンブログ作成」というテーマにはあまり関係ないが、next-routesの活用法として参考程度にはなるかと思う。
---
title: 投稿1
permalink: post1
date: 2020-01-01
thumb: images/2020-01-01-thumb.png
category: カテゴリー1
tag:
- タグ1
- タグ2
---
## 見出し1
ここに本文を書く。
動作確認
必要なファイルは揃ったので動作確認のためアプリ起動。
$ npm run dev
http://localhost:3000/に接続してサイト内を徘徊してみる。
新規投稿
今回作成したのはあくまでSPAであってCMSではないため、記事の投稿や編集を行うダッシュボード的な機能はない。記事を新規投稿するにはpages/[posts]/drawing/などにmdファイルを追加するしかない。その点は全くの初心者には難しいかも知れないが、少し勉強すればgitで管理できるようになるので、むしろ効率的ではある。
---
title: Next.js製マークダウンブログ完成
permalink: nextjs-markdown-blog-application-completed
date: 2020-01-05
thumb: images/2020-01-05-thumb.png
category: アプリ制作
tag:
- Next.js
- React.js
- マークダウン
- CMS
---
# これにて完結
ありがとうございました。
要まとめ
ページ構成とか、CSS編集とか、改善点とか。