리스트 페이지
우선 블로그 포스팅 리스트를 만들어 봅시다
app/posts/page.tsx 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 레이어 에 전달 해줍니다.
_pages/PostList.tsx export function PostList ({ posts } : { posts : Post [] }) {
return (
< PageLayout >
< PostsCategoryNav />
< br />
< PostItems posts = { posts } />
</ PageLayout >
);
}
위젯 레이어
Pages 레이어 에는 두 Widget 레이어 컴포넌트들로 구성되어 있습니다. 필터할 카테고리 목록을 보여주는 PostsCategoryNav
와 포스트 리스트를 보여줄 PostItems
입니다.
widgets/PostCategoryNav.tsx 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 >;
}
widgets/PostItems.tsx 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
컴포넌트가 담당합니다.
features/NavigateToHref.tsx 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 아키텍처를 활용한 페이지 구성입니다.
pages/PostDetail.tsx 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번만 다루도록 하겠습니다.
스크롤 진행도
스크롤 진행도를 보여주려면 윈도우 객체에 이벤트 리스너를 등록해 구현하였습니다.
widgets/ScrollProgressBar.tsx ' 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
가 필요해 클라이언트 컴포넌트 로 구현했습니다.
마크다운 본문
shared/MDXComponent.tsx 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 컴포넌트입니다
features/TableOfContents.tsx ' 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 파일을 이용해 블로그를 만드는 법을 간단하게 알아보았습니다.
추가적인 기능은 시간이 생길때마다 하나씩 더 작업할 에정입니다.
자세한 코드는 제 깃 레포지토리에서 확인해볼 수 있습니다.
블로그 레포지토리