[Next 포트폴리오] 02. 마크다운 라이브러리 세팅(mdx, contentlayer)

Chaejung·2024년 2월 4일
1
post-thumbnail

목표

정적 블로그를 위한 마크다운 라이브러리를 세팅한다.

기술 스택

  • typescript
  • NextJS 13
  • tailwindcss
  • yarn / pnpm

MDX

왜 MDX를 선택했는가

MDX는 Markdown와 JSX를 결합한 형식이다. 그래서 Markdown 문서에 React 컴포넌트를 넣을 수 있다.

# Hello, world!

<div className="note">
  > Some notable things in a block quote!
</div>

이를 좀 더 활용하면 동적이고 인터렉티브한 콘텐츠를 넣을 수 있기 때문에 NextJS 또는 Gatsby로 만들어진 정적 블로그에서도 꽤 많이 쓰는 형식이라고 한다. 필자는 글과 참고 자료의 가독성을 높이기 위해 캐러셀을 만들거나, 인터렉티브한 기능을 점진적으로 추가하기 위해 MDX 형식을 추가했다.

❓ 커스텀할 생각 없고, 정적인 콘텐츠만 쓸 예정이라면 그냥 Markdown 써도 될까?
상관없다, 하지만 이럴 경우 브라우저에서 렌더가 바로 되지 않는 Markdown 형식을 넣을 때 dangerouslySetInnerHTML에 의존해야 하므로 권장되지 않는다. 따라서 react-markdown과 같은 관련 라이브러리를 사용하는 것을 추천한다.

적용 방법

Next로 빌드된 프로젝트의 경우 NextJS 공식 문서에 친절하게 설명이 되어있다.

yarn add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
  • next.config.js
const withMDX = require('@next/mdx')()
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Configure `pageExtensions` to include MDX files
  pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], 
}
 
module.exports = withMDX(nextConfig)

❗️ 혹시 module 에러가 난다면 설정 파일의 확장명을 cjs로 변경하면 된다.

공식 문서에서는 mdx 파일의 위치는 app 디렉토리 내로 지정하면 된다고 하는데,
나는 최상위 폴더에 posts를 새로 만들어서 그곳에 mdx 파일들을 모아두었다. 그 이유는 후술할 예정이다.

my-project
┣📦posts
┃ ┣ 📂sample
┃ ┃ ┗ 📜index.mdx
┃ ┣ 📂sample-copy
┃ ┃ ┗ 📜index.mdx
┃ ┗ 📂sample-copy-2
┃ ┃ ┗ 📜index.mdx
...

만약 MDX 파일이 같은 프로젝트 내에 존재하는 게 아니라, 다른 서버로부터 동적으로 받아와야 하는 상황이라면 next-mdx-remote를 추가로 설치하여서 적용하면 된다. 주의할 점은 해당 MDX가 신뢰할 수 있어야 한다는 것이다.
참고: Markdown and MDX/Remote MDX - Next.js Docs

필자는 해당 순서 이후 레이아웃, 플러그인을 별도로 적용하진 않았다.

Contentlayer

왜 Contentlayer를 선택했는가

정적 블로그를 만들 때 있어서 과거부터 현재까지 컨텐츠 작업 방식은 크게 변해왔지만,
여전히 전통적인 CMS(Content Management System: 콘텐츠 관리 시스템)는 허들이 높다.
따라서 개발자가 콘텐츠와 작업하는 데 있어 어려운 이유를 대응하기 위해 만들어진 SDK가 Contentlayer이다.

Contentlayer는 현재 NextJS와 호환이 가능하다.(Remix, Vite, Astro 지원 예정)
이러한 모던 프레임워크와 함께 동작할 때 유효성 검사, TypeScript 지원, 라이브 리로딩 등의 기능을 제공하여 개발자 경험을 극대화한다.

이러한 장점이 현재 세팅한 프로젝트에 알맞다고 생각해서 Contentlayer를 선택하게 되었다.
아직 베타이기 때문에 불안정하고 문서가 부족한 게 흠이겠지만,
기능에 대한 매력을 더 크게 느껴 직접 부딪혀보고 싶은 마음이 먼저 들었다.

적용방법

Contentlayer 또한 Next로 빌드된 프로젝트의 경우 Contentlayer 공식문서에 설명이 되어있다.

환경 설정

yarn add contentlayer next-contentlayer date-fns

date-fns는 정렬하는 util 함수이니, 정렬 기능이 따로 없다면 제거하면 된다.

  • next.config.js
const { withContentlayer } = require("next-contentlayer");

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
  distDir: "build",
  experimental: {
    serverComponentsExternalPackages: ["@prisma/client"],
  },
  pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"],
};

module.exports = withContentlayer(nextConfig);
  • tsconfig.json
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    //  ^^^^^^^^^^^
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
      // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".contentlayer/generated"
    // ^^^^^^^^^^^^^^^^^^^^^^
  ]
}
  • .gitignore
# ...

# contentlayer
.contentlayer

데이터 스키마 정의

필자는 포스트별 메타데이터에 대해 다음과 같이 스키마를 정의했다.

  • contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer/source-files";

const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: `**/*.mdx`,
  contentType: "mdx",
  fields: {
    title: {
      type: "string",
      required: true,
    },
    thumbnail: {
      type: "string",
    },
    description: {
      type: "string",
      required: true,
    },
    series: {
      type: "string",
    },
    hashTags: {
      type: "list",
      of: { type: "string" },
    },
    postedAt: {
      type: "date",
      required: true,
    },
    readTime: {
      type: "number",
      required: true,
    },
  },
  computedFields: {
    url: {
      type: "string",
      resolve: (doc) => `/posts/${doc._raw.flattenedPath}`,
    },
  },
}));

export default makeSource({
  contentDirPath: "posts",
  documentTypes: [Post],
});

MDX 포맷의 파일에 대해 Post라는 문서 유형을 지정한다. 이러한 파일에서 생성된 모든 데이터에는 파일의 원시 및 HTML 콘텐츠와 직접 지정한 fields가 포함된다.

최하단의 contentDirPath는 MDX 포맷의 파일들을 모아둔 posts의 위치를 가리키면 된다. 그런데 필자는 project 최상위에 위치시켰으므로 posts로 지정했다.

만약 src/data 내부에 posts가 위치한다면, src/data/posts로 변경해야 한다.

❓ 왜 최상위에 위치하나?
첫 compile 시간을 비교해 보니 10배 정도 차이가 나서 가장 바깥으로 빼는 것으로 선택했다.

물론 이 방법이 공식 문서에서 제안한 그대로가 아니라 나만의 방식이니,
만약 자신만의 폴더 구조 규칙이나 근거가 있다면 따라가도 무방하다.

사이트 레이아웃 코드

  • src/app/posts/all/page.tsx
import { PostCard } from "@/components/PostCard";
import { allPosts, Post } from "contentlayer/generated";
//TODO: util 직접 짜기
import { compareDesc } from "date-fns";

export type PostMeta = Omit<Post, "_raw">;

export interface PostListElement {
  slug: string;
  meta: PostMeta;
}

const getAllPosts = async (): Promise<PostMeta[]> => {
  return allPosts.sort((a, b) =>
    compareDesc(new Date(a.postedAt), new Date(b.postedAt))
  );
};

const PostListPage = async () => {
  const postList = await getAllPosts();
  return (
    <div>
      <title>All Posts</title>
      {postList.map((post, idx) => (
        <PostCard key={idx} post={post} />
      ))}
    </div>
  );
};

export default PostListPage;
  • src/app/posts/[slug]/page.tsx
import { allPosts } from "contentlayer/generated";
import { useMDXComponent } from "next-contentlayer/hooks";

const getSinglePost = (slug: string) => {
  const singlePost = allPosts.findIndex(
    (doc) => doc._raw.flattenedPath === slug
  );
  return allPosts[singlePost];
};

const PostPage = ({ params: { slug } }: { params: { slug: string } }) => {
  const singlePost = getSinglePost(slug);

  const MDXContent = useMDXComponent(singlePost.body.code);
  return (
      <MDXContent />
  );
};

export default PostPage;

❗️ Cannot find module 'contentlayer/generated' or its corresponding type declarations 에러 발생 시

  • 원인: contentlayer는 mdx 파일을 추적한 뒤 json으로 빌드되는데, 이런 프로젝트 세팅에 따라(참고 이슈에서는 버전 문제가 원인) 빌드가 트리거 되지 않는 문제가 발생하기 때문이다
  • 해결 방법:
  "scripts": {
    "dev": "contentlayer dev & next dev",
    "build": "contentlayer build & next build",
    "start": "next start",
    "lint": "next lint"
  },

참고: Cannot find module 'contentlayer/generated' or its corresponding type declarations - stackoverflow

앞으로 추가하고 싶은 것

  • code renderer
    현재 mdx 파일로 코드를 작성하면 스타일이 적용되어 있지 않아서 밋밋하게 나온다.

    이런 식으로 테마를 적용할 수 있는 라이브러리를 찾아놓았는데, 적용할 예정이다.

  • Header, Footer
    원래 와이어프레임과 디자인을 하고 나서 시작하려고 했지만,
    완벽하게 하려는 욕심때문에 시작이 딜레이되고 있었기에 냅다 시작했다.
    그래서 현재는 없지만, 추후 개발하면서 맘 가는대로 추가할 예정이다.

  • ReadTime
    글을 보려고 마음 먹기 전 얼마나 걸리는지도 꽤 중요한 지표임을 느끼고 있었다.
    이 또한 관련 라이브러리가 있지만, 한국어로 정확한 계산이 안되는 듯하여, 한 번 util로 직접 만들어 보는 것도 좋은 시도일 것 같다.

  • 조회수 측정
    이 부분은 아직 레퍼런스를 못 찾았는데, 고민을 해봐야 겠다.

느낀 점

작년 이후로 작업하는 것을 거의 잊어버렸던 프로젝트인데,
이번에 먼지 털어서 다시 재구성하니 개운하고, 뿌듯하다.
역시 게으른 완벽주의자를 위한 솔루션은 '냅다 시작'이다.

다른 사람들의 멋진 기술 블로그를 보면서
'나도 언젠가는 저분들과 같은 멋쟁이가 되어야지'라는 마음을 품었었다.
그 지점을 찍기 위한 첫 발걸음을 힘차게 내딛은 것 같은 기분이다.

profile
프론트엔드 기술 학습 및 공유를 활발하게 하기 위해 노력합니다.

0개의 댓글