😇
React,Viteでブログ機能を実装する方法
制作物
使用ライブラリ
- markdown-it
- zenn-content-css
= zenn-embed-elements
zenn-markdown-htmlでは内部でNode.js の cryptoを使用しており、これはNodeJS環境では動くが、ブラウザでは動かなかったので、markdown-itを使用しています
SSGなどを使用してmd→htmlのパース処理をサーバー側で実行するようにしたら動くと思われるが、これは試していないのでより良い方法をご存じの方がいれば教えていただけると嬉しいです。
ディレクトリ構成
src/
├── routes/
│ ├── blog/
│ │ ├── $slug/
│ │ │ └── index.lazy.tsx
│ │ ├── index.lazy.tsx
│ │ ├── -utils/
│ │ │ ├── blogData.ts
│ │ │ └── markdownParser.ts
├── _posts/
│ ├── example.md
記事のデータ管理
ブログ記事は Markdown で作成し、import.meta.glob を使用して読み込むことで、ビルド時に記事データを静的に取得します。
blogData.ts
const markdownFiles = import.meta.glob('/src/_posts/*.md', {
eager: true,
as: 'raw',
});
type Post = {
content?: string;
date: string;
excerpt: string;
slug: string;
title: string;
};
const allPosts: Post[] = Object.entries(markdownFiles).map(
([filePath, content]) => {
const slug = filePath.split('/').pop()?.replace('.md', '') || '';
const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)/.exec(content as string);
if (!match) {
throw new Error(`Invalid frontmatter in ${slug}.md`);
}
const frontMatterLines = match[1].split('\n').filter(Boolean);
const metadata: Record<string, string> = {};
frontMatterLines.forEach((line) => {
const [key, ...value] = line.split(': ');
metadata[key.trim()] = value.join(': ').trim();
});
return {
slug,
title: metadata.title || 'No Title',
date: metadata.date || 'Unknown Date',
excerpt: metadata.excerpt || '',
content: match[2].trim(),
};
}
);
export const getAllPosts = async () => {
return allPosts.map(({ slug, title, date, excerpt }) => ({ slug, title, date, excerpt }));
};
export const getPostBySlug = async (slug: string) => {
return allPosts.find((post) => post.slug === slug) || null;
};
Markdown のパース
Markdown 記事を HTML に変換するために MarkdownIt を使用します。
markdownParser.ts
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({
html: true,
linkify: true,
});
export const parseMarkdown = (markdown: string) => {
return md.render(markdown);
};
記事一覧ページの作成
記事の一覧ページでは getAllPosts() を使用して記事データを取得し、一覧表示を行います。
index.lazy.tsx
import { useState, useEffect } from 'react';
import { createLazyFileRoute, Link } from '@tanstack/react-router';
import { getAllPosts } from './-utils/blogData';
export const BlogList = () => {
const [posts, setPosts] = useState([]);
useEffect(() => {
getAllPosts().then(setPosts);
}, []);
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">ブログ一覧</h1>
<ul>
{posts.map((post) => (
<li className="mb-4" key={post.slug}>
<Link className="text-blue-500 hover:underline" to={`/blog/${post.slug}`}>
<h2 className="text-xl font-bold">{post.title}</h2>
</Link>
<p className="text-gray-500">{post.date}</p>
<p>{post.excerpt}</p>
</li>
))}
</ul>
</div>
);
};
export const Route = createLazyFileRoute('/blog/')({
component: BlogList,
});
記事詳細ページの作成
記事の詳細ページでは、slugを元に記事データを取得し、Markdown を HTML に変換して表示します。
$slug/index.lazy.tsx
import { useEffect, useState } from 'react';
import { createLazyFileRoute, useParams } from '@tanstack/react-router';
import { getPostBySlug } from '../-utils/blogData';
import { parseMarkdown } from '../-utils/markdownParser';
import 'zenn-content-css';
export const BlogPost = () => {
const { slug } = useParams({ from: '/blog/$slug/' });
const [post, setPost] = useState(null);
useEffect(() => {
const fetchPost = async () => {
const fetchedPost = await getPostBySlug(slug);
if (fetchedPost) {
setPost({
...fetchedPost,
content: parseMarkdown(fetchedPost.content || ''),
});
}
};
fetchPost();
}, [slug]);
if (!post) return <p>Loading...</p>;
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold">{post.title}</h1>
<p className="text-gray-500">{post.date}</p>
<div className="znc" dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
);
};
export const Route = createLazyFileRoute('/blog/$slug/')({
component: BlogPost,
});
PR
今後やりたいこと
まだ記事ごとの動的OGPを設定できていないので、追加したいな思っています。
また、関連記事を表示したり、タグ検索を可能にしたり、有料機能をつけたりなど、やりたいことはたくさんあるので、ゆるゆると機能を追加していこうと考えております。
最後に
この記事へのいいねとリポジトリへのスターをもらえるととても励みになって嬉しいです。
また、Xもやっていますのでよろしければフォローください!
エンジニアの方はフォローバックします!
参考
Discussion