Next.js14とGitHub Pagesでブログを作ってみる: スケルトン
November 09, 2024
目指すは簡単にカスタマイズできるブログのスケルトンを作ること。それではレイアウトの構想から。
レイアウト
.
├── app
│ └── posts
│ └── [slug]
│ └── page.tsx
├── components
├── contents
│ └── posts
├── lib
└── public
app/
-
posts/[slug]/page.tsx
- * ブログの記事ページ
- * 動的ルーティング[1-1]を使うことでMDファイルでURLを指定できる
- * resume(CV)やprojects, aboutページなどの追加にも対応できる
- * 今このページも同じ構成になっている
- ↪︎ https://gyute.com/posts/nextjs-14-blog-2-ja ≈ app/posts/[slug]
-
-
https://nextjs.org/docs/14/app/building-your-application/routing#route-segments
components/
- * ヘッダー、フッター、サイドバーなどのコンポーネントを集めるディレクトリ
- * Next.jsはfile-system based routerというコンセプトを持っているため、このディクトリはURLのパスとは関係ない
contents/
-
posts/
- * ブログのコンテンツになるMDファイルを格納するディレクトリ
- * 開発が終わってからは、このディレクトリだけ行き来すれば良い
lib/
- * ユーティリティやカスタムフックなどを集めるディレクトリ
- * dateやMDファイルの読み込みなどの処理をここに集める
public/
- * 同じくNext.jsのコンセプトにより、assetsは
public/
に置かないといけないお約束 - * MDファイルで使われる画像などが入る
- ↪︎ ex)
public/ポスト別/MDに入るイメージ.png
- ↪︎ ex)
mkdir -p app/posts/\[slug\] components contents/posts lib public
ディレクトリを一個一個掘るのも大変なので一気に作る。
.
├── app
├── components
├── contents
├── lib
└── public
実装追加: 基本レイアウト[commit]
- * 緑色の部分が追加された部分(diff)
- * 補足が必要だと思われる部分はコードの下に記載
app/layout.tsx
import "./globals.css";
import { Metadata } from "next";
import { BLOG_TITLE } from "@/components/constants";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
<body className="bg-slate-300">
<Header />
<main className="min-h-[63vh] sm:min-h-[67vh]">{children}</main>
<Footer />
</body>
</html>
);
}
export const metadata: Metadata = {
title: BLOG_TITLE,
};
app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply scrollbar-gutter-stable mx-auto max-w-screen-lg scroll-smooth font-mono;
}
}
@layer utilities {
.scrollbar-gutter-stable {
scrollbar-gutter: stable;
}
}
- * L13: スクロールバーの有無でレイアウトの崩れ(ガタツキ)がないように
scrollbar-gutter
を常に設定する
components/constants.ts
export const BLOG_TITLE = "@ブログタイトル";
export const AUTHOR = "ユーザー名";
export const HOME_ROUTE = "/";
export const ABOUT_ROUTE = "/";
components/header.tsx
"use client";
import Link from "next/link";
import React from "react";
import { ABOUT_ROUTE, BLOG_TITLE, HOME_ROUTE } from "./constants";
export const Header: React.FC = () => {
return (
<header className="pb-14 pt-4">
<div className="container mx-auto flex items-center justify-between">
<div>
<h1 className="my-0">
<Link href={"/"}>{BLOG_TITLE}</Link>
</h1>
</div>
<div className="flex space-x-5">
<nav>
<ul className="flex space-x-5">
<li>
<Link href={HOME_ROUTE}>Home</Link>
</li>
<li>
<Link href={ABOUT_ROUTE}>About</Link>
</li>
</ul>
</nav>
</div>
</div>
</header>
);
};
- * L1: ヘッダーはinteractive要素が多いため
"use client"
を追加- * SSGでビルドをするため
"use client"
を追加しなくても、"next/link"
は使える- ↪︎
<a>タグ
に変換されるため
- ↪︎
- * SSGでビルドをするため
- * L21, L24:
Home
とAbout
はダミー
components/footer.tsx
import React from "react";
import { AUTHOR } from "./constants";
export const Footer: React.FC = () => {
return (
<footer className="pb-4 pt-20">
<div className="container mx-auto flex justify-between text-center">
<h3>Copyright © 2024 {AUTHOR}</h3>
</div>
</footer>
);
};
実装追加: Markdown ポスティング[commit]
まずはパッケージをインストール。
npm install gray-matter unified shiki remark-parse remark-rehype rehype-stringify
app/page.tsx
export default function Home() {
return <></>;
import Link from "next/link";
import { formatDate } from "@/lib/date";
import { Post, getAllPosts } from "@/lib/posts";
export default function Blog() {
const posts: (Post | null)[] = getAllPosts();
return (
<div className="container mx-auto">
{posts?.map((post) => {
if (post === null || !post.date || !post.slug || !post.title)
return null;
const date = formatDate(post.date);
return (
<div
key={post.slug}
className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3 md:gap-0"
>
<div className="text-base text-gray-500">{date}</div>
<h3 className="ml-7">
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
</h3>
</div>
);
})}
</div>
);
}
app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import { formatDate } from "@/lib/date";
import { getPostBySlug, getPostSlugs } from "@/lib/posts";
export default async function Post({
params: { slug },
}: {
params: { slug: string };
}) {
const post = getPostBySlug(slug);
if (!post?.content) {
return notFound();
}
const html = (
await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStringify, { allowDangerousHtml: true })
.process(post.content)
).toString();
return (
<>
<div className="container mx-auto">
<h1 className="my-5 text-2xl font-bold">{post.title}</h1>
<p className="mb-12 text-lg text-gray-500">{formatDate(post.date)}</p>
<div
className="my-3 text-lg"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</>
);
}
export function generateStaticParams() {
const params = getPostSlugs().map((slug) => ({ slug }));
if (params.length === 0) {
return [{ slug: "not-found" }];
}
return params;
}
- * L11: リンターの働きで行替えになっているが、
params: { slug }
がこのファイルの[slug]
[1-2]に対応している(取ってきている) - * L13: TypeScriptの型定義で
slug
がstring
であることを示している - * L20-26:
remark
とrehype
を使ってMarkdown
をHTML
に変換- * L22:
remark-parse
: MDをAST(Abstract Syntax Tree)に変換- ↪︎ parser
- * L23:
remark-rehype
: ASTをHTML ASTに変換 - * L24:
rehype-stringify
: HTML ASTをHTMLに直列化(シリアライズ, serialization)- ↪︎ compiler
- *
allowDangerousHtml
はMDファイルにHTMLタグを埋め込むことを可能にする- ↪︎ MDファイルが掲示板の投稿、リモートアクセスなどの信頼できないソースから取得される場合、
XSS(Cross-Site Scripting)
脆弱性のリスクがあるため、デフォルトではfalse
になっているが、今回は自分で作成したローカルのMDファイルでビルドするため使用 - ↪︎ MDファイルの中のHTMLタグを読み込むために、
rehype-raw
を使うこともできるが、rehype-stringify
のオプションで十分なので余分なパッケージは入れたくなかった
- ↪︎ MDファイルが掲示板の投稿、リモートアクセスなどの信頼できないソースから取得される場合、
- * L26:
unified()
はVFileオブジェクト
返すので、toString()
でHTML文字列に変換 - * L35: 上で処理したHTMLをJSX(TSX)に変換せずにそのままレンダリング
- * L22:
- L42-48:
generateStaticParams()
: SSGビルド時、動的ルーティングをスタティックに生成するための関数- * L45: 仕様上、
slug
がない場合は、return [];
を返すよう公式に書いてあるが、そうするとビルドができない問題があり、臨時的にこのような書き方を取っている- ↪︎
not-found
ページは存在しない - ↪︎ あくまでも
slug
がない時だけの話で、slug(MDファイル)
がある場合は関係ない
- ↪︎
- * L45: 仕様上、
contents/posts/.gitkeep
Whitespace-only changes.
- * このファイルは
空ファイル
で、内容は意味を持たない。空のcontents/posts/
ディレクトリをGitに追跡させるために追加してある[2]- ↪︎ Gitは空のディレクトリを追跡しないためPushできないが、この仕様によって
lib/posts.ts
のgetPostSlugs()
がディレクトリを探せず、ビルドが失敗する- ↪︎ この件も、
return [{ slug: "not-found" }];
と似た話で、slug(MDファイル)
がある場合は関係ない(消しても良い)
- ↪︎ この件も、
- ↪︎ Gitは空のディレクトリを追跡しないためPushできないが、この仕様によって
contents/posts/lipsum.md
---
title: "lorem ipsum"
date: 2024-11-07
description: "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit"
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. <a href="https://en.wikipedia.org/wiki/Lorem_ipsum" class="text-green-700">(Source)</a>
- * L1-5: このブロックがMDファイルのメタデータ(Front Matter)になる
- * L7: MDファイルの中でHTMLタグを使っている
- ↪︎ 続くTailwindCSSの設定で、MDファイルの中でもTailwindCSSのクラスを使えるようにする
- ↪︎ JSX(TSX)ファイルの中でTailwindCSSを使う時との違いは、
className
ではなく、class
にすること
- ↪︎ JSX(TSX)ファイルの中でTailwindCSSを使う時との違いは、
- ↪︎ 続くTailwindCSSの設定で、MDファイルの中でもTailwindCSSのクラスを使えるようにする
lib/date.ts
export function formatDate(dateString: string) {
const locales = "en-US";
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "2-digit",
};
const date = new Date(dateString);
return date.toLocaleDateString(locales, options);
}
lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
export type Post = {
title: string;
description?: string;
date: string;
slug: string;
content: string;
};
const contentsDirPath = path.join(process.cwd(), "contents/posts");
export function getPostSlugs(): string[] {
try {
if (!fs.existsSync(contentsDirPath)) {
console.error(`Directory not found: ${contentsDirPath}`);
return [];
}
const fileNames = fs.readdirSync(contentsDirPath);
return fileNames
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => fileName.replace(/\.md$/, ""));
} catch (err) {
console.error(`Error reading directory: ${(err as Error).message}`);
return [];
}
}
export function getPostBySlug(slug: string): Post | null {
const mdPath = path.join(contentsDirPath, `${slug}.md`);
try {
if (!fs.existsSync(mdPath)) {
console.error(`File not found: ${mdPath}`);
return null;
}
const md = fs.readFileSync(mdPath, "utf8");
const { data: frontMatter, content } = matter(md);
return {
title: frontMatter.title,
description: frontMatter.description,
date: frontMatter.date,
slug,
content,
};
} catch (err) {
console.error(`Error reading file: ${(err as Error).message}`);
return null;
}
}
export function getAllPosts(): (Post | null)[] {
const slugs = getPostSlugs();
if (slugs.length === 0) {
return [];
}
return slugs
.map((slug) => getPostBySlug(slug))
.filter((post) => post !== null && post.date)
.sort((a, b) => new Date(b!.date).getTime() - new Date(a!.date).getTime());
}
tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}"],
content: [
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./contents/**/*.md",
],
theme: {},
plugins: [],
};
export default config;
- * L8: MDファイルの中でもTailwindCSSのクラスを使えるようにする
終わりに
これでスケルトンが完成した。
ついでに、TailwindCSS本家のtailwindcss-typographyを使うことで、簡単にブログのスタイルをプロのように整えることができるのでシンプルな導入方法も残しておく[commit]。
npm install @tailwindcss/typography
カスタマイズはできるが、このプラグインのデフォルトのmax-widthやフォントの色、大きさなどが個人的に合わず、逆に修正箇所が多くなり追えなくなったので、私は使っていない。
機会があれば、このブログで使っているTailwindCSSの設定やダークモードに関しても書いていきたい。
その時にはまたblog-exampleリポジトリを更新していくので、blog-exampleリポジトリ
を<user>.github.io
のリポジトリ名にしてフォークしてもらったら、最新の情報を取得できるようになる。
1: SSG環境でももちろん動的ルーティング(Dynamic Routes)を使える。[括弧の中]
が必ずslug
である必要はなく、別に[banana]
でも良いが命名は慎重にしていきたい。return 1-1↩ | return 1-2↩
2: .gitkeep
はde facto standardのようなもので、今回は無理やりディレクトリをPushしたかったので入れてあるがMDファイルを追加したら削除しても良い(残しておいても良い)。[stack overflow記事] return↩