Next.js에서 MDX 컴포넌트를 스타일링하기 (편?하게)

Gomi·2023년 4월 20일

(마지막에 컴포넌트 공유함)

1. MDX in NextJS


MDX 확장자는 마크다운(MD)과 JSX가 결합되어 마크다운 컨텐츠를 리액트 내에서 컴포넌트 형태로 export하거나, JSX컴포넌트를 MDX파일 내에 import 할 수 있도록한다. 정적 컨텐츠를 컴포넌트화 시키는 특성 때문에 SSG를 지원하는 프레임워크(Gatsby, Next)에서는 MDX를 위한 플러그인이 잘 지원되고 있다.

이 글에서는 그 중 NextJS의 MDX 플러그인을 통해 마크다운 컨텐츠를 스타일링 하는 법을 다룬다.



2. MDX 구문 분석기


npm install @next/mdx @mdx-js/loader @mdx-js/react

NextJS의 기본적인 MDX 플러그인들을 설치 후

// next.config.js

const withMDX = require('@next/mdx')({
  extension: /\.mdx?$/,
  options: {
    // If you use remark-gfm, you'll need to use next.config.mjs
    // as the package is ESM only
    // https://github.com/remarkjs/remark-gfm#install
    remarkPlugins: [],
    rehypePlugins: [],
    // If you use `MDXProvider`, uncomment the following line.
    // providerImportSource: "@mdx-js/react",
  },
})

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Configure pageExtensions to include md and mdx
  pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
  // Optionally, add any other Next.js config below
  reactStrictMode: true,
}

// Merge MDX config with Next.js config
module.exports = withMDX(nextConfig)

next config 를 다음과 같이 설정해주면, mdx 확장자의 마크다운 구문을 html태그(JSX)로 변환하는 작업이 가능해진다.

# h1
## h2

같은 구문이 있다면

<h1>h1</h1>
<h2>h2</h2>

로 변환되는 식이다.
이런 변환과정에 디테일하게 관여하기 위해 remark(md to html) rehype(html to md) 플러그인을 사용할 수 있다. NextJS로 만들어진 tailwindCSS 홈페이지의 소스코드를 보면 직접 작성된 플러그인 예시를 볼 수 있다. 솔직히 너무 복잡하여 참고만...ㅠㅠ



3. 어떻게 스타일링할까


mdx 확장자는 별도의 export문 없이도 하나의 mdx컴포넌트로 default export된다. JSX 내에서 JSX 구문처럼 선언할 수 있다. 이는 'mdx/types' 모듈에서 살펴보면 MDXContent로 정의되고 있다.

/**
 * The props that may be passed to an MDX component.
 */
export interface MDXProps {
    /**
     * Which props exactly may be passed into the component depends on the contents of the MDX
     * file.
     */
    [key: string]: unknown;

    /**
     * This prop may be used to customize how certain components are rendered.
     */
    components?: MDXComponents;
}

/**
 * The type of the default export of an MDX module.
 */
export type MDXContent = (props: MDXProps) => JSX.Element;

MDXContent 타입은 props로 components라는 객체를 받아 mdx확장자에서 변환되는 태그와 매핑할 수 있다.

// index.mdx
# h1
## h2

같은 파일을 JSX에서 다음과 같이 불러와

import Content from 'index.mdx';

const components = {
	h1: //jsx component
  	h2: //jsx component
}

function page(){
	return(
    	<Content components ={components}/>
    )
}

h1, h2 태그에 대응할 컴포넌트를 직접 매핑해줄 수 있다는 것이다. 하지만 import하는 mdx 컨텐츠가 여러개인 경우 일일이 components를 props로 집어넣어 매핑해줘야하는 불편함 때문에 context api를 통해 구현된 MDXProvider를 사용한다.

모든 페이지 컨텐츠에 MDX가 사용된다면 _app.tsx 파일 하나에 MDXProvider사용을 생각해볼 수도 있겠으나, 범용성을 위해 따로 MDXProvider가 적용된 layout 컴포넌트를 만드는게 좋을 것이다.



4. github-markdown-css


마크다운을 위해 css를 별도로 작성하는 건 부담스러운 일일 수 있다. 깃허브의 마크다운 스타일과 동일하게 제공하는 듯한 npm 패키지가 있어 추천한다. (github-markdown-css)

npm install github-markdown-css

설치 후,

상위 컴포넌트의 className으로 "markdown-body"만 넣어주면 나머지 태그들은 깃허브 스타일로 깔끔하게 정돈된다. MDXProvider의 아래 div태그를 넣고 거기 달아주면 될 것 같다.



5. prism-react-renderer


MDXProvider에 제공할 수 있는 컴포넌트는 img, h1, h2, p, pre, code 등 다양하다. img에 제공할 컴포넌트는 NextJS Docs에서 이미 제공중이고, 문제가 되는건 코드블럭이다. 백틱 세 개(```)로 표현하는 코드블럭을 위한 컴포넌트는 아마 github-markdown-css 만으로는 만족스러운 스타일을 내기 힘들 것이다.

그래서

const components = {
	code: // 여기 들어갈 컴포넌트
}

저기 들어갈 컴포넌트를 하나 만들어주려 한다.
여기서 code syntax Highlighting을 위해 prism-react-renderer 라이브러리를 활용했다.

npm install --save prism-react-renderer

라이브러리를 활용해 codeBlock 컴포넌트를 만드는법은 다양하다. 해당사이트에 여러가지 예시가 있으니 참고하면 좋다.

필자가 최종적으로 코드블럭을 위해 만든 컴포넌트는 다음과 같다. 파일명이나, 라인넘버는 없지만 추가하려면 적절히 응용하면 된다.

// components/mdxViewer/codeBlock.tsx

/* eslint-disable react/no-array-index-key */
import React from 'react';
import Highlight, { defaultProps, Language } from 'prism-react-renderer';
import duotoneLight from 'prism-react-renderer/themes/duotoneLight';

interface CodeBlockProps{
  children: string;
  className: string;
}

export default ({ children, className }:CodeBlockProps) => {
  const language = className.replace(/language-/, '');

  return (
    <Highlight
      {...defaultProps}
      theme={duotoneLight}
      code={children}
      language={language as Language}
    >
      {({
        // eslint-disable-next-line no-shadow
        className, style, tokens, getLineProps, getTokenProps,
      }) => (
        <pre className={className} style={{ ...style }}>
          {tokens.map((line, i) => (
            <div key={i} {...getLineProps({ line, key: i })}>
              {line.map((token, key) => (
                <span key={key} {...getTokenProps({ token, key })} />
              ))}
            </div>
          ))}
        </pre>
      )}
    </Highlight>
  );
};


6. 최종적으로 생성된 MDX Viewer



// components/mdxViewer/index.tsx

import React from 'react';
import { MDXProvider } from '@mdx-js/react';
import { MDXComponents } from 'mdx/types';
import CodeBlock from './codeBlock';
import 'github-markdown-css';

interface MDXProps{
  children: React.ReactNode;
}

const components = {
  code: CodeBlock,
  img: // 이미지는 생략합니다.
};

export default function MDXLayout({ children }:MDXProps) {
  return (
    <>
      <style jsx>
        {`
          .markdown-body{
            padding: 20px;
          }
        `}
      </style>
      <MDXProvider components={components as MDXComponents}>
        <div className="markdown-body">
          {children}
        </div>
      </MDXProvider>
    </>
  );
}

다음 MDXLayout 컴포넌트를

import React from 'react';
import MDXLayout from '@/components/mdxViewer';
import Content from '@/contents/progressbar.mdx';

export default () => (
  <>
    <MDXLayout>
      <Content />
    </MDXLayout>
  <>

);

사용 시 둘러싸 사용하거나, mdx파일에서 직접 둘러싸 export 하는 등의 방법이 있다. 굳이 따지면 후자가 좀 더 좋을 것 같다.

어쨌든 어느정도 완성도 있는 마크다운 뷰어를 직접(?) 구현했지만 여러 석연치 않은 점은 있다.
github-markdown-css를 사용한다면, .markdown-body라는 클래스 선택자의 자식으로 태그 선택자를 이용하기 때문에 mdx컨텐츠와 mdx컨텐츠 사이에 JSX 컴포넌트가 들어오려면

export default () => (
  <>
    <MDXLayout>
      <Content />
    </MDXLayout>
    <MyJSXComponent>
      <h1></h1>
      <h2>이럴수가</h1>
    </MyJSXComponent>
    <MDXLayout>
      <Content2 />
    </MDXLayout>
  <>

);

다음과 같이 Provider를 남발해야 할 수도 있다.
하지만 마크다운 뷰어의 직접 구현에 대한 자료가 너무 없는 것 같아 나의 해결법을 공유하고자 했다.



좋은 방법이 있다면 공유해주십사!

profile
터키어 배운 롤 덕후