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

Gomi·2023년 4월 20일
4

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

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
터키어 배운 롤 덕후