구현
여러분이 보고 계신 이 블로그를 어떻게 구현했는지에 대해 다루려고 합니다.
앞서 소개해드린 것 처럼, 이 블로그는 Next.js를 기반으로 하는 Nextra를 사용해서 구현했습니다.
또, 게시글은 MDX 문서로 작성합니다.
MDX 문서로 작성한 게시글을 어떻게 웹페이지로 랜더링하는지, 그렇게 만들어진 페이지 목록을 어떻게 조회하고 필터링하는지에 대해 소개해드리려고 합니다.
MDX 문서를 웹페이지로 랜더링하는 과정
Nextra는 MDX 문서를 파싱하고 웹페이지로 랜더링합니다.
이 때, 웹 페이지를 어떻게 랜더링할지는 mdx-compoonents.tsx라는 파일에서 named export하는 useMDXComponents를 읽고 결정합니다.
mdx-components.tsx
1export const useMDXComponents = (components: ReactElement[]) => ({
2 ...defaultComponents,
3 ...components,
4});
5여기서 구조분해할당하는 defaultComponents객체를 커스터마이징해야 합니다.
getNextraComponents()로 게시글 컴포넌트를 커스터마이징
"MDX를 파싱한 페이지를 이렇게 보여주어야 합니다"
getNextraComponents는 이름처럼 Nextra의 기본 컴포넌트 맵핑 정보를 반환하는 함수입니다.
인자를 통해 원하는 컴포넌트를 우리가 만든 컴포넌트로 교체할 수 있습니다.
또, 해당 함수는 서버 사이드에서 호출되는 함수입니다. 따라서 비동기 함수를 사용할 수 있으며 서버 전용 API를 호출할 수 있습니다.
1const defaultComponents = getNextraComponents({
2 // mdx 페이지 컴포넌트.
3 wrapper: async ({ children, toc, metadata, bottomContent }) => {
4 /**
5 * 서버 전용 API를 호출해서 페이지 랜더링에 필요한 series, tags, post등의 정보를 조회.
6 * 해당 코드가 길어서 생략했습니다!
7 */
8
9 return (
10 <>
11 <PostDetail toc={toc} series={series} tags={tags} post={post} bottomContent={bottomContent}>
12 {children}
13 </PostDetail>
14 </>
15 );
16 },
17 // 이미지 컴포넌트 오버라이드
18 img: PostImage,
19 // 코드블록(```) 컴포넌트 오버라이드
20 pre: PostCodeblock,
21});
22wrapper는 MDX 페이지의 최상위 레이아웃을 감싸는 역할을 합니다.
이걸 사용해서 게시글 페이지를 커스터마이징할 계획입니다.
게시글 상세페이지라고 해서 본문만 보여주진 않죠.
헤더, 푸터, TOC(Table of contents), 이전, 다음 게시글 버튼도 보여줘야 합니다.
아래의 코드에서 보시는 것 처럼 커스터마이징할 수 있습니다.
1export function PostDetail({ children, series, post, bottomContent, toc }: PostDetailProps) {
2 // ...생략
3
4 return (
5 <ArticleContainer>
6 <section>
7 <ArticleHeader right={<TOC toc={toc} />} />
8
9 <Divider />
10
11 {/* MDX를 파싱해서 생성한 DOM 엘리먼트의 스타일은 module.scss로 생성한 클래스로 처리합니다. */}
12 <article className={classes.postDetail}>
13 {/* 여기에 MDX를 파싱해서 생성한 DOM 엘리먼트가 들어갑니다 */}
14 {children}
15 </article>
16
17 <footer>
18 <PostNavigator mode="prev" post={post.prevPost} />
19 <PostNavigator mode="next" post={post.nextPost} />
20 </footer>
21 </section>
22 </ArticleContainer>
23 );
24}
25MDX를 파싱하여 생성한 DOM 엘리먼트는 children props에 담겨있습니다.
이 엘리먼트에 스타일을 적용하기 위해, module.css로 생성한 클래스를 사용했습니다.
1.postDetail {
2 h1,
3 h2,
4 h3 {
5 font-weight: 700;
6 padding: 20px 0;
7 }
8
9 p {
10 padding-top: 16px;
11 padding-bottom: 24px;
12 line-height: 28px;
13 word-break: keep-all;
14 }
15
16 ul,
17 ol {
18 padding: 5px 0;
19 }
20 // ...생략
21}
22위와 같이 postDetail 클래스는 하위 h1, h2, p, ul등의 엘리먼트의 스타일을 정의합니다.
그리고 module.css로 작성했기에 클래스 이름은 고유한 해시로 변환되어 전역 네임스페이스와 충돌하지 않습니다.
따라서 게시글 컴포넌트에만 스타일을 적용할 수 있어서 사용했습니다.
MDX 페이지 목록을 조회하는 방법
"랜딩 페이지는 게시글 목록을 보여줘야 합니다."
랜딩 페이지나 시리즈 페이지에선 게시글 목록을 보여주어야 합니다.
그런데 게시글은 mdx 문서인데다, 게시글의 제목, 생성일과 같은 메타 정보는 mdx를 파싱해야 알 수 있습니다.
이 문제는 Nextra의 getPageMap과 normalizePages을 사용해서 해결할 수 있습니다.
이름에서 알 수 있는 것 처럼, getPageMap은 인자로 지정한 경로의 모든 페이지 맵 정보를 반환합니다.
normalizePages는 getPageMap이 반환한 페이지 맵 정보를 가공합니다.
Navbar, TOC, Post List등의 컴포넌트 구현에 적합한 형태로 데이터를 가공한다고 보시면 됩니다.
기본 테마를 사용한다면, 반환객체를 그대로 사용하면 됩니다만, 커스텀 테마를 직접 구현하는 경우에도, 이걸 활용할 수 있습니다.
아래는 예시 코드입니다.
1import { getPageMap } from 'nextra/page-map';
2import { Item, normalizePages } from 'nextra/normalize-pages';
3
4export async function findPosts(param: FindPostsProps = GetPostDefaultOption) {
5 const { orderBy, limit, seriesId } = prepareParam<FindPostsProps>(param, GetPostDefaultOption);
6 const fullRoute = resolveSeriesQuery(seriesId);
7
8 // getPageMap으로 지정한 경로의 모든 페이지 정보를 가져온다.
9 const pageMap = await getPageMap(fullRoute);
10
11 // 가져온 페이지 정보를 가공한다.
12 const { directories } = normalizePages({
13 list: pageMap,
14 route: '/posts',
15 });
16
17 // 필요한 만큼 데이터를 추가 가공한다(배열 평탄화, 필터링, 정렬, ...)
18 let posts = directories
19 .reduce((acc, curr) => collectPost(acc, curr), [] as Item[])
20 .flat()
21 .filter((post) => post.frontMatter)
22 .filter((post) => !post.frontMatter.isSeriesLanding);
23
24 if (orderBy) {
25 posts.sort(sortPostByCreatedAt);
26 }
27
28 if (limit) {
29 posts = posts.filter((_, i) => i < limit);
30 }
31
32 // 지정한 경로 밑의 모든 게시글 목록 반환
33 return posts;
34}
35서버 컴포넌트에서 위의 findPosts를 호출하고 게시글 목록을 props로 내려줄 수 있습니다.
이를 응용하여 랜딩 페이지에서 게시글 목록을 보여주는 기능을 구현할 수 있습니다.
아래는 서버 컴포넌트 예시입니다.
1export default async function LandingPage() {
2 const seriesModels: SeriesModel[] = await getSeries();
3 const seriesPosts: Record<string, PostModel[]> = {};
4
5 for (const series of seriesModels) {
6 if (series.id === Constants.series.latestId) {
7 continue;
8 }
9 // findPosts를 통해 지정한 경로의 모든 게시글 목록을 조회
10 const seriesPost = await findPosts({
11 limit: 20,
12 seriesId: series.id,
13 });
14
15 // 클라이언트 컴포넌트가 의존하는 타입인 PostModel로 변환
16 seriesPosts[series.id] = seriesPost.map((item) => Mapper.toPostModel({ item, seriesModels }));
17 }
18
19 // Props로 게시글 목록 내려주기
20 return <MainClientPage seriesPosts={seriesPosts} />;
21}
22클라이언트 컴포넌트는 props를 통해 받은 게시글 목록을 사용해서 랜딩페이지의 게시글 목록을 보여줄 수 있습니다.
이러한 페이지는 SSG 방식으로 빌드됩니다.
따라서 애플리케이션 빌드 시점에 서버 컴포넌트의 로직을 실행하여 가져온 데이터를 기반으로 정적 웹 페이지를 만들게 됩니다.
새로운 포스트를 작성했다면, 새로 커밋하고 푸시해서 앱을 리빌드해야 목록에 새로운 포스트가 추가되는 방식입니다.
정리하면…
- Nextra의 API를 사용해서 MDX 문서를 파싱하여 생성한 페이지를 커스터마이징 할 수 있습니다.
- getNextraComponents를 사용해서 Nextra 기본 컴포넌트 맵핑을 커스터마이징할 수 있습니다.
- mdx로 생성한 페이지 목록은 getPageMap으로 가져옵니다. 그리고 normalizePage로 사용하기 쉽게 가공합니다.
긴 글 읽어주셔서 감사합니다!