😇

React,Viteでブログ機能を実装する方法

2025/02/13に公開

制作物

https://my-dq-portfolio.vercel.app/blog

使用ライブラリ

  • markdown-it
  • zenn-content-css
    = zenn-embed-elements

https://github.com/markdown-it/markdown-it

https://www.npmjs.com/package/zenn-content-css

https://www.npmjs.com/package/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

https://github.com/developerhost/my-dq-portfolio/pull/82

今後やりたいこと

まだ記事ごとの動的OGPを設定できていないので、追加したいな思っています。

また、関連記事を表示したり、タグ検索を可能にしたり、有料機能をつけたりなど、やりたいことはたくさんあるので、ゆるゆると機能を追加していこうと考えております。

最後に

この記事へのいいねとリポジトリへのスターをもらえるととても励みになって嬉しいです。

また、Xもやっていますのでよろしければフォローください!
エンジニアの方はフォローバックします!

https://github.com/developerhost/my-dq-portfolio

https://x.com/dall_develop

参考

https://zenn.dev/team_zenn/articles/intro-zenn-markdown

Discussion