こんにちは、木瓜丸です。
今回、このサイトをリニューアルしてみました。
自分がやっていることを色々見えるようにすることが結構大事な気がしてきたので、 今度こそ頑張ってブログのカキコとメンテナンスを続けたいと思います。
さて、今回このサイトを作るにあたって、Next.jsのApp Routerを使ったので、簡単に使用技術についてまとめておきたいと思います。
このサイトの構成
今回はデータソースにはheadless CMS等は使わず、ファイルに直接markdownを書いてSSGする形にしました。
- Next.js
- remark
- デプロイ先: Vercel
App Routerとは
App RouterはNext.js 13にて追加された新機能です。 12以前とは異なるディレクトリ構造、異なるPageコンポーネントの書き口で実装できるようになります。
従来のNext.jsでは、/pages
配下に設置したコンポーネントに対して、ファイル名を元にルーティングされました。
├── public/
└── pages/
└── index.tsx -> /からルーティングされるページ
対して、App Routerでは/app
配下に設置されたlayout.tsx
とpage.tsx
がページコンポーネントとなります。
├── public/
└── app/
├── layout.tsx -> このディレクトリ配下の共通のレイアウト
└── page.tsx -> /からルーティングされるページ
最初見た時は気持ち悪かったですが、小さなコンポーネントが増えてくるとディレクトリの整理が圧倒的にしやすくなりました。
また、generateStaticParams
やMetadata
といった、他の13の新機能とのシナジーがかなり設計されていると感じました。
ディレクトリ構成
記事データは_posts/
配下に配置しています。
後述しますが、src/libs/articles.ts
に_posts/
配下から記事一覧、各記事データを生成する処理をまとめています。
.
├── _posts/
├── _works.json
├── next.config.js
├── node_modules
├── package.json
├── public/
├── src/
│ ├── app/
│ │ ├── _components/
│ │ ├── apple-icon.png
│ │ ├── blog/
│ │ │ ├── [slug]/
│ │ │ │ ├── page.module.scss
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── page.module.scss
│ │ │ └── page.tsx
│ │ ├── colors.scss
│ │ ├── favicon.ico
│ │ ├── globals.scss
│ │ ├── layout.tsx
│ │ ├── opengraph-image.png
│ │ ├── page.module.scss
│ │ ├── page.tsx
│ │ └── twitter-image.png
│ └── libs
│ └── articles.ts
├── tsconfig.json
└── yarn.lock
記事データの処理
src/libs/articles.ts
はこんなかんじになっております。
import * as fs from 'fs';
import * as path from 'path';
import * as glob from 'glob';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeHighlight from 'rehype-highlight';
import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import gfm from 'remark-gfm';
import rehypeStringify from 'rehype-stringify';
import rehypeDocument from 'rehype-document';
export interface ArticleProps {
slug: string;
title: string;
content: string;
thumbnail?: string | null;
html: string;
tags: string;
publishedAt: string;
}
export const getAllArticles = (): ArticleProps[] => {
const fileNames = fs.readdirSync(path.join(process.cwd(), "./_posts/"));
return fileNames.map((fileName) => {
return getArticle(fileName);
});
}
export const getArticle = (slug: string): ArticleProps => {
const remarkHtml = unified()
.use(remarkParse)
.use(remarkRehype)
.use(gfm)
.use(rehypeHighlight)
.use(remarkMath)
.use(rehypeKatex)
.use(rehypeDocument, {
css: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css'
})
.use(rehypeStringify);
const file = fs.readFileSync(path.join(process.cwd(), "./_posts/", slug, "index.md"), 'utf8')
const matterResult = matter(file)
const thumbnailPaths = glob.globSync(path.join(process.cwd(), "./public/", slug, "thumbnail.*"));
const thumbnail = thumbnailPaths.length > 0 ? thumbnailPaths[0] : null;
const html = remarkHtml.processSync(matterResult.content).toString();
return {
slug,
title: matterResult.data.title,
tags: matterResult.data.tags,
publishedAt: matterResult.data.publishedAt,
content: matterResult.content,
thumbnail,
html
};
}
記事一覧は次のような手順で生成します。
_posts/
ディレクトリ配下のディレクトリ一覧を取得する- ディレクトリ一覧のディレクトリ名をslugとして、さらにその配下の
index.md
を読む
frontmatterはgray-matter
パッケージを使って解析します。
また、markdown→htmlの変換にはremarkを使いました。
ページの生成処理
ブログ記事のページコンポーネント(src/app/blug/[slug]/page.tsx
)は下記のようになっています。
import { getAllArticles, ArticleProps, getArticle } from '@/libs/articles';
import { Metadata } from 'next';
...
interface ArticlePageProps {
params: ArticleProps
}
export default function Article({ params }: ArticlePageProps) {
const article = getArticle(params.slug)
return (
...
)
}
export const generateStaticParams = () => {
return getAllArticles()
}
export const generateMetadata = async ({ params }: {
params: {
slug: string
}
}): Promise<Metadata> => {
const { title, content, thumbnail } = getArticle(params.slug);
return {
title,
description: content,
openGraph: {
title,
description: content,
images: [
process.env.VERCEL_URL + (thumbnail ?? '/noimage.png')
]
},
twitter: {
title,
description: content,
images: [
process.env.VERCEL_URL + (thumbnail ?? '/noimage.png')
]
}
}
}
generateStaticParams
は、静的ファイルを生成する時のページ一覧を動的に生成するためのメソッドです。
このメソッドではDynamic Routesのプレースホルダの値一覧を返却します。
なので、ここではgetAllArticles()
の結果を返却していますが、実際に使われるのはその中のslug
プロパティだけです。
そのため、コンポーネント内で再度getArticle(parameter.slug)
という形で記事の内容を再取得しています。
generateMetadata
では、OGPの内容を動的に設定できます。
これだけでもかなりありがたいですが、VercelにOGPのバリデーターが入っていて、結構大変なOGPの設定がメチャクチャやりやすくなりました。
思ったこと・やり残したこと
現在別のアプリもApp Routerで作っていますが、小規模なアプリや静的ページはすごく作りやすいなという印象です。
ただ、特に静的配信回りではまだ知見がたまっていないなという感じもしました。
今回ブログを書くにあたってmarkdownと画像を同じディレクトリで管理したりしてみたかったのですが、next-images
やnext-optimized-images
は動作しませんでした。
今後進展があったらまた記事にしたいと思います。何か知っている方いらっしゃいましたら連絡下さいmm