리스트 페이지
우선 블로그 포스팅 리스트를 만들어 봅시다
export default async function Page({ searchParams }: { searchParams: Promise<{ filter?: string }> }) {
const { filter } = await searchParams;
const { posts } = new PostsImpl(filter);
return <PostList posts={posts} />;
}
리스트 페이지는 filter라는 쿼리 파라미터로 리스트를 카테고리별로 필터링 할 수 있게 만들었습니다. Next.js
를 이용한 서버 컴포넌트에서는 hook을 사용할 수 없기 때문에 page.tsx
에서 쿼리 파라미터를 searchParams props에서 가져와 사용하도록 합시다. 가져온 filter 값을 인스턴스에 주입시켜서 포스트 목록을 생성하여 Pages 레이어에 전달해줍니다.
export function PostList({ posts }: { posts: Post[] }) {
return (
<PageLayout>
<PostsCategoryNav />
<br />
<PostItems posts={posts} />
</PageLayout>
);
}
위젯 레이어
Pages 레이어에는 두 Widget 레이어 컴포넌트들로 구성되어 있습니다. 필터할 카테고리 목록을 보여주는 PostsCategoryNav
와 포스트 리스트를 보여줄 PostItems
입니다.
export function PostsCategoryNav() {
const categories = new CategoriesImpl();
return (
<ul className='flex flex-wrap'>
{categories.getAll().map((category, index) => {
if (category.name === '전체') {
return (
<NavigateToHref key={`category_${index}`} href='/posts'>
<SingleCategory category={category} />
</NavigateToHref>
);
} else {
return (
<NavigateToHref key={`category_${index}`} href={`/posts?filter=${category.name}`}>
<SingleCategory category={category} />
</NavigateToHref>
);
}
})}
</ul>
);
}
function SingleCategory({ category }: { category: { name: string; fileCount: number } }) {
return <li className='m-1 rounded bg-cool-gray-reverse p-2 text-warm-gray'>{`${category.name} (${category.fileCount})`}</li>;
}
export function PostItems({ posts }: { posts: Post[] }) {
return posts.map((post, index) => {
return (
<div key={`post_${index}`}>
<PostItemCard post={post} />
{posts.length - 1 !== index && <div className='h-4' />}
</div>
);
});
}
function PostItemCard({ post }: { post: Post }) {
return (
<NavigateToHref href={`/posts/${post.category}/${post.id}`}>
<div className='rounded-lg border border-warm-gray p-4'>
<h2 className='truncate text-xl font-semibold'>{post.data.title}</h2>
<div className='h-2' />
<p className='min-h-16 truncate text-pretty break-words'>{post.data.description}</p>
<div className='h-2' />
<div className='flex justify-end text-cool-gray'>
<img src={'/clock.svg'} alt='' className='aspect-square w-4 object-contain' />
<div className='w-1' />
<span>{post.readingTime}</span>
<div className='w-2' />
<span>{post.data.createdAt}</span>
</div>
</div>
</NavigateToHref>
);
}
각각의 컴포넌트들은 그려주는 역할만 하고 네비게이트 이벤트는 Features 레이어의 NavigateToHref
컴포넌트가 담당합니다.
export function NavigateToHref({ children, href, replace, isBlank }: { children: React.ReactNode; href: string; replace?: boolean; isBlank?: boolean }) {
return (
<Link href={href} className='no-underline' replace={replace} target={isBlank ? '_blank' : ''}>
{children}
</Link>
);
}
상세 페이지
상세 페이지도 앞서 살펴본 리스트 페이지처럼 FSD 아키텍처를 활용한 페이지 구성입니다.
export function PostDetail({ post }: { post: PostImpl }) {
return (
<PageLayout>
<ScrollProgressBar />
<div className='relative'>
<PostSummary category={post.category} data={post.data} readingTime={post?.readingTime} />
<MDXComponent content={post.content} />
<div className='h-12' />
<Profile />
<div className='h-6' />
<TableOfContents />
</div>
</PageLayout>
);
}
상세 페이지에는 좀 더 다양한 컴포넌트들로 구성되어 있습니다.
- 스크롤 정도에 따라 진행도를 보여주는
ScrollProgressBar
- 마크다운 프론트메터를 보여주는
PostSummary
- 마크다운 본문을 파싱해주는
MDXComponent
- 프로필 컴포넌트
Profile
- 헤더 인덱스들을 보여주는
TableOfContents
여기서 2, 4는 데이터를 그대로 보여주는 역할밖에 없기 때문에 1, 3, 5번만 다루도록 하겠습니다.
스크롤 진행도
스크롤 진행도를 보여주려면 윈도우 객체에 이벤트 리스너를 등록해 구현하였습니다.
'use client';
import { useEffect, useState } from 'react';
export function ScrollProgressBar() {
const [progress, setProgress] = useState('');
useEffect(() => {
window.addEventListener('scroll', () => {
const currentY = window.scrollY;
const totalY = window.document.documentElement.scrollHeight - window.innerHeight;
setProgress(((currentY / totalY) * 100).toFixed(3));
});
}, []);
return <div className={`fixed left-0 top-0 z-10 h-2 bg-warm-gray`} style={{ width: `${progress}%` }} />;
}
진행도를 동적으로 관리해야하는데 useEffect
와 useState
가 필요해 클라이언트 컴포넌트로 구현했습니다.
마크다운 본문
export function MDXComponent({ content }: { content?: string }) {
return (
<article className='prose mx-auto w-full'>
<MDXRemote
source={content || ''}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm, remarkBreaks, [remarkToc, { heading: 'structure' }]],
rehypePlugins: [
[
rehypePrettyCode,
{
keepBackground: false,
theme: { dark: 'plastic', light: 'github-light' }
}
],
rehypeSlug,
[
rehypeAutolinkHeadings,
{
properties: {
className: ['anchor']
}
}
]
]
}
}}
components={{
a: ({ children, href, ...rest }) => {
return (
<a {...rest} target='_blank' href={href?.toString()}>
{children}
</a>
);
},
img: (imageComponent) => {
return <img {...imageComponent} className='aspect-video rounded-lg bg-warm-gray object-contain' />;
}
}}
/>
</article>
);
}
마크다운 본문은 MDXRemote를 통해 스타일을 별도로 지정하지 않고 간단하게 바꿀 수 있도록 하였습니다. 필요한 플러그인들을 넣어줘 편하게 마크다운을 html로 변환하여 관리할 수 있도록 하였습니다.
components 프롭스는 html로 변환하는 과정에서 일치하는 태그를 오버라이딩 할 수 있게 도와줍니다. 저는 a태그와 img태그를 제가 원하는 데로 수정하였습니다.
현재는 options나 components가 그렇게 많지 않아 인라인으로 작성하였으나, 좀 더 규모가 커진다면 분리해서 관리할 예정입니다.
TOC
마지막으로 헤더들을 모아두고, 네비게이트까지 담당하는 TableOfContents 컴포넌트입니다
'use client';
import { useEffect, useState } from 'react';
export function TableOfContents() {
const [toc, setToc] = useState<{ id: string; text: string; level: number }[]>([]);
const [activeId, setActiveId] = useState('');
useEffect(() => {
const headings = window.document.querySelector('article')?.querySelectorAll('h2, h3, h4') as NodeListOf<HTMLElement>;
const extractedHeadings = Array.from(headings).map((heading) => ({ id: heading.id, text: heading.innerText, level: parseInt(heading.tagName.substring(1), 10) }));
setToc(extractedHeadings);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: '0% 0% -95% 0%' }
);
headings.forEach((element) => {
observer.observe(element);
});
return () => {
headings.forEach((element) => {
observer.unobserve(element);
});
};
}, []);
const handleScroll = (id: string) => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<aside className='absolute left-full top-0 hidden h-full w-52 sm:block'>
<nav className='sticky top-[10vh] ml-8 w-52 rounded-lg bg-cool-gray-reverse p-4'>
<p className='font-semibold text-warm-gray'>contents.</p>
<div className='h-2' />
<ul className='text-sm'>
{toc.map((content, index) => {
return (
<li
key={`toc_${index}`}
className={`cursor-pointer`}
onClick={(e) => {
e.preventDefault(); // 기본 해시 이동 동작 방지
handleScroll(content.id);
}}
style={{ marginLeft: `${(content.level - 2) * 8}px`, color: content.id === activeId ? '#f3aa51' : '', marginBottom: toc.length - 1 !== index ? '4px' : '' }}
>
{content.text}
</li>
);
})}
</ul>
</nav>
</aside>
);
}
이 컴포넌트도 hooks를 사용해야하기 때문에 클라이언트 컴포넌트로 구현하였습니다.
전역 객체를 활용해 현재 그려지는 html에서 헤더 태그들을 모아 IntersectionObserver를 활용해 스크롤을 감지하여 강조표시를 할 수 있도록 구현하였습니다.
마무리
이렇게 Next.js와 markdown 파일을 이용해 블로그를 만드는 법을 간단하게 알아보았습니다.
추가적인 기능은 시간이 생길때마다 하나씩 더 작업할 에정입니다.
자세한 코드는 제 깃 레포지토리에서 확인해볼 수 있습니다.