Zenithium 제작기

마크다운 불러오기 3탄

remark, rehype 플러그인을 만들고 적용하기

2024-10-08 작성

평균 23분 소요

341회 조회

#Next.js#Blog#MDX#Rehype#Remark
cover

이번에는 remark, rehype 플러그인을 적용하고, 직접 만드는 방법까지 소개하도록 하겠습니다.

플러그인 적용하기

MDXRemote 컴포넌트에 사용할 플러그인을 아래와 같이 적용하면 됩니다. 플러그인에 적용할 옵션이 있는 경우, 배열 안에 플러그인과 옵션을 함께 담으면 적용됩니다.

옵션은 해당 플러그인의 공식 문서를 참고하세요.

{
  plugins: [plugin1, plugin2, [plugin3, pluginOptions3]];
}
plugin3에는 옵션을 적용한 모습

저는 아래와 같은 remark, rehype 플러그인을 적용했어요.

CustomMDX.tsx
<MDXRemote
  {...props}
  components={{ ...components, ...(props.components || {}) }}
  options={{
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkBreaks, remarkUnwrapImages],
      rehypePlugins: [
        [rehypePrettyCode, prettyCodeOptions],
        rehypeMessageBox,
        rehypeSlug,
        rehypeAddRelativeToHeadings,
        [rehypeAutolinkHeadings, AutoLinkOptions],
      ],
    },
  }}
/>

이 중에 rehypeMessageBoxrehypeRelativeToHeadings는 직접 구성한 커스텀 플러그인입니다. 아래의 항목에서 따로 다룰 예정이니 참고해 주세요.

먼저 제가 적용한 외부 플러그인을 소개하겠습니다. 해당 플러그인의 사용을 고민하고 있는 분들에게 참고가 되면 좋겠네요.

remark-gfm

remark-gfm은 마크다운에 링크, 취소선, 표 등을 표현할 수 있도록 하는 플러그인 입니다.
특히 체크리스트를 사용하는 분들에게 매우 유용한 플러그인이니 필요하신 분들은 사용하시면 좋습니다.

npm install -D remark-gfm

각주 기능도 사용 가능하지만 제 블로그에서는 링크가 _blank로 동작하고 있어 예시에 추가하지 않았습니다.

  1. 자동 링크 추가
작성 예시
www.google.com

www.google.com

  1. 취소선
작성 예시
~취소~ ~~취소선~~

취소 취소선

  1. 테이블
작성 예시
| 순번 | 이름   |   생년월일 |   국적   |
| :--- | :----- | ---------: | :------: |
| 1    | 홍길동 | 2000-01-01 | 대한민국 |
| 2    | 김철수 | 2010-01-01 | 대한민국 |
순번이름생년월일국적
1홍길동2000-01-01대한민국
2김철수2010-01-01대한민국
  1. 체크리스트
작성 예시
- [ ] 할 일
- [x] 완료한 일
  • 할 일
  • 완료한 일

remark-breaks

remark-breaks는 마크다운에서의 줄바꿈을 편하게 처리할 수 있는 플러그인 입니다.

npm install -D remark-breaks

마크다운에서 줄바꿈을 표시하기 위해서 두 가지 방법중 하나를 사용해야 합니다.

  • 문장 마지막에 공백을 2개 이상 추가하기
  • 문장 마지막에 백슬래시(\) 추가하기

세 가지중 하나를 고른다고 해도 매 줄바꿈마다 무언가를 추가해야 한다는 것이 여간 번거로운 일이 아니죠.

이런 불편함을 해소하고자 나온 플러그인이 바로 remark-breaks 입니다. Enter키를 눌러서 줄바꿈을 하면 이를 <br/> 태그로 변경해 주기 때문에 편하게 줄바꿈을 표현할 수 있습니다.


remark-unwrap-images

remark-unwrap-images는 이미지를 감싸고 있는 <p> 태그를 제거하는 플러그인 입니다.

npm install -D remark-unwrap-images

파싱 과정에서 <img><p> 가 감싸게 되는데 이 과정에서 의도치 않은 스타일 변경이 발생할 수 있습니다. 이를 원하지 않는 사용자를 위해 만들어진 플러그인입니다.

저는 이미지의 캡션을 구현하기 위해 이미지를 <figure> 로 감싸는 플러그인을 제작했으나 아래와 같은 오류가 발생해 이 플러그인을 사용했습니다.

<figure> cannot appear as a descendant of <p>

이후 이미지 컴포넌트를 직접 제작해 적용하도록 리팩토링 하면서 Zenithium에서는 해당 플러그인을 사용하지 않게 되었습니다.


rehype-pretty-code

제가 블로그를 제작하면서 가장 적용하고 싶었던 플러그인이 바로 이 플러그인입니다.

rehype-pretty-code는 코드 블록을 읽기 편하게 포맷팅 해주고 라인 넘버링, 라인 하이라이팅 등 다양한 기능을 제공하는 플러그인입니다.

npm install -D rehype-pretty-code

이 플러그인에는 적용할 수 있는 옵션이 있는데 공식 문서에 자세히 나와있으니 선택해서 적용하시면 됩니다. 저는 테마 옵션만 적용하였습니다.

rehype-pretty-codeShiki라는 하이라이터를 사용하고 있고 테마 또한 Shiki에서 지원하고 있습니다.

const prettyCodeOptions: RehypePrettyCodeOptions = {
  theme: { dark: "material-theme-darker", light: "github-light" },
};
Shiki는 다크, 라이트 테마를 지원한다

추가적으로 Tailwind Typography에서 Shiki 색상 변수를 사용할 수 있습니다.

{
  "pre": {
    "paddingRight": 0,
    "paddingLeft": 0,
    "color": "var(--shiki-light)",
    "backgroundColor": "var(--shiki-light-bg)",
    "border": "1px solid hsla(var(--muted-foreground) / 0.2)"
  },
 
  ".dark pre": {
    "backgroundColor": "var(--shiki-dark-bg)",
    "color": "var(--shiki-dark)"
  }
}
다크, 라이트 테마에 대한 텍스트, 배경 색상을 할당해야 한다

이렇게 플러그인 설정을 마치고 나면 rehype-pretty-code의 많은 기능을 이용할 수 있습니다. 제가 가장 많이 쓰는 기능들만 간단하게 추려봤어요.

  1. 코드 블록 라인 하이라이팅

원하는 코드 라인에 하이라이트를 추가할 수 있습니다.

작성 예시
```js {1-2,4}
const hello = "hello";
const world = "world";
 
console.log(hello + world);
```
결과
const hello = "hello";
const world = "world";
 
console.log(hello + world);
  1. 인라인 코드 하이라이팅

인라인 코드도 하이라이트를 추가할 수 있습니다.

작성 예시
`<html>{:html}` `.class{:css}` `console.log("hi"){:js}`

<html> .class console.log("hi")

  1. 단어 하이라이팅

각 단어마다 하이라이트를 추가할 수 있습니다. 해당하는 모든 단어를 하이라이팅하기 때문에 원하는 단어만 하이라이팅 하고 싶으면 옆에 해당하는 단어의 순번을 적어주면 됩니다.

작성 예시
```js "hello" "world"2-3
const hello = "hello";
const world = "world";
 
console.log(hello + world);
```
결과
const hello = "hello";
const world = "world";
 
console.log(hello + world);
  1. 라인 넘버링

코드 라인 넘버를 표시할 수 있습니다.

작성 예시
```js showLineNumbers
const hello = "hello";
const world = "world";
 
console.log(hello + world);
```
결과
const hello = "hello";
const world = "world";
 
console.log(hello + world);

원하는 숫자부터 라인 넘버를 시작하고 싶은 경우, {원하는 숫자}를 옆에 추가하면 됩니다.

작성 예시
```js showLineNumbers{10}
const hello = "hello";
const world = "world";
 
console.log(hello + world);
```
결과
const hello = "hello";
const world = "world";
 
console.log(hello + world);
  1. 타이틀, 캡션 추가

코드 블록에 타이틀과 캡션을 추가하여 코드에 대한 부가 정보를 표시할 수 있습니다.

작성 예시
```js title="hello-world.js"
const hello = "hello";
const world = "world";
 
console.log(hello + world);
```
 
```js caption="템플릿 리터럴 방식으로도 작성할 수 있다"
console.log(`${hello}${world}`);
```
hello-world.js
const hello = "hello";
const world = "world";
 
console.log(hello + world);
console.log(`${hello}${world}`);
템플릿 리터럴 방식으로도 작성할 수 있다

rehype-slug

rehype-slug<h1>과 같은 헤딩 태그를 찾아 id 속성을 추가하는 플러그인입니다.

npm install -D rehype-slug

TOC(목차) 를 구성할 때, 목차에 링크를 추가하여 해당 내용으로 바로 이동할 수 있도록 만드는데 유용합니다. id는 요소의 innerText를 적절하게 변형하여 사용하는데 이는 github-slugger의 규칙을 따르고 있습니다.

이 플러그인을 사용하면서 TOC를 제작해야 한다면, 동일한 파싱 알고리즘을 따라야 하므로 TOC에서 github-slugger를 사용하면 좋습니다.


rehype-autolink-headings는 각 헤딩 태그에 자기 자신의 id 속성으로 이동하는 <a>를 추가합니다.

npm install -D rehype-autolink-headings

rehype-autolink-headings는 추가 옵션을 적용할 수 있고 저는 해시(#) 텍스트를 추가해 스타일링 하기 위한 옵션을 적용했어요.

const AutoLinkOptions: RehypeAutoLinkOptions = {
  properties: {
    className: ["anchor"],
  },
  content: [
    {
      type: "element",
      tagName: "span",
      properties: {
        className: "text-primary text-2xl absolute hidden lg:block lg:-left-6",
      },
      children: [{ type: "text", value: "#" }],
    },
  ],
};
anchor 클래스네임을 부여하고 자식 요소로 해시를 추가하는 옵션

제 글의 부제목을 클릭하면 해당 파트로 스크롤이 이동하는 것을 볼 수 있어요.

클릭한 제목의 파트로 스크롤 이동


플러그인 직접 만들기

이렇게 플러그인을 설치해서 적용하는 방법도 있지만 직접 제작해서 사용할 수도 있습니다. 저는 Github의 Alert 마크다운 문법에서 착안한 메시지박스를 플러그인으로 만들었어요.

작성 예시
::: note 메모
 
::: tip 꿀팁
 
::: important 중요
 
::: warning 주의
 
::: caution 경고

메모

꿀팁

중요

주의

경고

그럼 이런 플러그인은 어떻게 만들까요?

unist-util-visit

rehype 플러그인을 만드는데 있어서 가장 중요한 것은 HTML 트리에서의 개별 노드를 탐색하는 과정입니다. 원하는 사인이 담겨있는 노드를 발견하면 그 때부터 플러그인이 동작해 해당 노드를 원하는대로 수정해야 하기 때문이죠.

이 때 unist-util-visit 을 사용해 트리를 순회하고 노드를 탐색할 수 있습니다. 더 자세한 내용은 여기에 나와있으니 플러그인을 만드실 때 참고해주세요.

npm install -D unist-util-visit @types/hast
타입스크립트는 hast 타입 패키지를 추가로 설치해야 한다.
커스텀 플러그인 기본 구성
import { visit } from "unist-util-visit";
import { Root, Element } from "hast";
 
const examplePlugin = () => {
  return (tree: Root) => {
    visit(tree, "element", (node: Element) => {
      // 노드 수정 로직 작성
    });
  };
};

플러그인의 기본 구성은 이렇게 되어 있습니다. 동작 원리를 간단히 살펴볼까요?

먼저 트리를 탐색합니다. visit 함수를 통해 트리를 탐색하면서 2번째 인자로 넘겨준 형태의 자식 노드인지 체크합니다.

export function visit<Tree extends import("unist").Node, Check extends Test>(
  tree: Tree,
  check: Check,
  visitor: BuildVisitor<Tree, Check>,
  reverse?: boolean | null | undefined
): undefined;
visit 함수 타입. 3번째 인자인 visitor의 타입을 2번째 인자로 체크한다

만약 체크를 통과하면 3번째 인자로 넘진 visitor 콜백 함수를 실행하게 됩니다.

export type VisitorResult =
  | Action
  | [(void | Action | null | undefined)?, (number | null | undefined)?]
  | Index
  | null
  | undefined
  | void;
/**
 * Handle a node (matching `test`, if given).
 *
 * Visitors are free to transform `node`.
 * They can also transform the parent of node (the last of `ancestors`).
 *
 * Replacing `node` itself, if `SKIP` is not returned, still causes its
 * descendants to be walked (which is a bug).
 *
 * When adding or removing previous siblings of `node` (or next siblings, in
 * case of reverse), the `Visitor` should return a new `Index` to specify the
 * sibling to traverse after `node` is traversed.
 * Adding or removing next siblings of `node` (or previous siblings, in case
 * of reverse) is handled as expected without needing to return a new `Index`.
 *
 * Removing the children property of an ancestor still results in them being
 * traversed.
 */
visitor 함수의 결과 타입. 노드 수정시 주의사항이 적혀 있으니 읽어 보는 것을 추천한다

노드를 제거하거나 추가하는 경우에 주의해야할 내용이 조금 있지만 저는 노드의 내용만 수정할 것이기 때문에 가볍게 읽어보고 넘어갔습니다.

rehype-message-box 플러그인 만들기

그럼 이제 메시지박스 플러그인을 만들기 위한 노드 탐색 로직을 구성해야겠죠? 간단한 문장으로 나열해 보겠습니다.

  1. 파싱된 모든 일반 문장은 <p> 태그에 담기게 되므로 먼저 <p>를 찾는다.
  2. children 속성에 담긴 텍스트가 ::: 기호로 시작하는지 판단한다.
  3. :::뒤에 쓰여있는 메시지박스의 타입(note, tip, important 등)을 찾는다.
  4. 해당 노드에 스타일링을 위한 클래스네임을 추가한다.
  5. 텍스트에서 ::: 기호와 메시지박스 타입을 제거한다.
  6. 아이콘 svg를 Element로 제작한다.
  7. 제작한 svg Element를 5번 항목에서 가공한 텍스트와 함께 담아 노드의 기존 children을 대체한다.

이렇게 정리된 내용대로 코드로 옮겨 적으면 다음과 같습니다.

plugin.ts
export const rehypeMessageBox = () => {
  return (tree: Root) => {
    visit(tree, "element", (node: Element) => {
      // 1. p 태그 찾기
      if (
        node.tagName === "p" &&
        node.children &&
        node.children[0] &&
        node.children[0].type === "text"
      ) {
        const firstChildValue = node.children[0].value.trim();
 
        // 2. ::: 기호로 시작하는지 판단하기
        if (firstChildValue.startsWith(":::")) {
          // 3. 메시지박스 타입 찾기
          const type = firstChildValue.split(" ")[1];
 
          // Note: node.properties와 className이 없으면 초기화
          node.properties = node.properties || {};
 
          // Note: className이 배열인지, 아니면 문자열/숫자인지 확인
          // Note: 배열이 아니면 배열에 담음
          let classNames: (string | number)[] = [];
          if (Array.isArray(node.properties.className)) {
            classNames = node.properties.className;
          } else if (
            typeof node.properties.className === "string" ||
            typeof node.properties.className === "number"
          ) {
            classNames = [node.properties.className];
          }
 
          // 4. 클래스네임 추가
          classNames.push("message-box", `message-box-${type}`);
 
          node.properties.className = classNames;
 
          // 5. 텍스트에서 ::: 기호와 메시지박스 타입 제거
          const combinedText = node.children[0].value
            .replace(/^:::\s*\w+/, "") // Note: ::: 및 그 뒤의 type 제거
            .trim();
 
          // 6. 아이콘 svg Element 제작
          const svgIcon: Element = {
            type: "element",
            tagName: "svg",
            properties: {
              className: `message-icon message-icon-${type}`,
              xmlns: "http://www.w3.org/2000/svg",
            },
            children: [
              {
                type: "element",
                tagName: "path",
                properties: {
                  fill: "currentColor",
                  d: path[type],
                },
                children: [],
              },
            ],
          };
 
          // 7. 노드의 children 대체
          node.children = [svgIcon, { type: "text", value: combinedText }];
        }
      }
    });
  };
};

시행착오를 많이 겪으면서 만든 첫 플러그인 입니다. 스타일링은 Tailwind Typography를 통해서 주입한 클래스네임의 스타일을 추가하는 방식으로 진행했어요.

아이콘의 경우 svg의 d를 Github Alert 아이콘에서 추출해 path라는 이름의 객체 변수로 담아두었습니다. 추출한 메시지박스의 타입으로 사용할 수 있도록 key를 타입 이름으로 지정했습니다.

export const path: { [key: string]: string } = {
  note: "M 0 8 a 8 8 0 1 1 16 0 A 8 8 0 0 1 0 8 Z m 8 -6.5 a 6.5 6.5 0 1 0 0 13 a 6.5 6.5 0 0 0 0 -13 Z M 6.5 7.75 A 0.75 0.75 0 0 1 7.25 7 h 1 a 0.75 0.75 0 0 1 0.75 0.75 v 2.75 h 0.25 a 0.75 0.75 0 0 1 0 1.5 h -2 a 0.75 0.75 0 0 1 0 -1.5 h 0.25 v -2 h -0.25 a 0.75 0.75 0 0 1 -0.75 -0.75 Z M 8 6 a 1 1 0 1 1 0 -2 a 1 1 0 0 1 0 2 Z",
  important:
    "M 0 1.75 C 0 0.784 0.784 0 1.75 0 h 12.5 C 15.216 0 16 0.784 16 1.75 v 9.5 A 1.75 1.75 0 0 1 14.25 13 H 8.06 l -2.573 2.573 A 1.458 1.458 0 0 1 3 14.543 V 13 H 1.75 A 1.75 1.75 0 0 1 0 11.25 Z m 1.75 -0.25 a 0.25 0.25 0 0 0 -0.25 0.25 v 9.5 c 0 0.138 0.112 0.25 0.25 0.25 h 2 a 0.75 0.75 0 0 1 0.75 0.75 v 2.19 l 2.72 -2.72 a 0.749 0.749 0 0 1 0.53 -0.22 h 6.5 a 0.25 0.25 0 0 0 0.25 -0.25 v -9.5 a 0.25 0.25 0 0 0 -0.25 -0.25 Z m 7 2.25 v 2.5 a 0.75 0.75 0 0 1 -1.5 0 v -2.5 a 0.75 0.75 0 0 1 1.5 0 Z M 9 9 a 1 1 0 1 1 -2 0 a 1 1 0 0 1 2 0 Z",
  warning:
    "M 6.457 1.047 c 0.659 -1.234 2.427 -1.234 3.086 0 l 6.082 11.378 A 1.75 1.75 0 0 1 14.082 15 H 1.918 a 1.75 1.75 0 0 1 -1.543 -2.575 Z m 1.763 0.707 a 0.25 0.25 0 0 0 -0.44 0 L 1.698 13.132 a 0.25 0.25 0 0 0 0.22 0.368 h 12.164 a 0.25 0.25 0 0 0 0.22 -0.368 Z m 0.53 3.996 v 2.5 a 0.75 0.75 0 0 1 -1.5 0 v -2.5 a 0.75 0.75 0 0 1 1.5 0 Z M 9 11 a 1 1 0 1 1 -2 0 a 1 1 0 0 1 2 0 Z",
  tip: "M 8 1.5 c -2.363 0 -4 1.69 -4 3.75 c 0 0.984 0.424 1.625 0.984 2.304 l 0.214 0.253 c 0.223 0.264 0.47 0.556 0.673 0.848 c 0.284 0.411 0.537 0.896 0.621 1.49 a 0.75 0.75 0 0 1 -1.484 0.211 c -0.04 -0.282 -0.163 -0.547 -0.37 -0.847 a 8.456 8.456 0 0 0 -0.542 -0.68 c -0.084 -0.1 -0.173 -0.205 -0.268 -0.32 C 3.201 7.75 2.5 6.766 2.5 5.25 C 2.5 2.31 4.863 0 8 0 s 5.5 2.31 5.5 5.25 c 0 1.516 -0.701 2.5 -1.328 3.259 c -0.095 0.115 -0.184 0.22 -0.268 0.319 c -0.207 0.245 -0.383 0.453 -0.541 0.681 c -0.208 0.3 -0.33 0.565 -0.37 0.847 a 0.751 0.751 0 0 1 -1.485 -0.212 c 0.084 -0.593 0.337 -1.078 0.621 -1.489 c 0.203 -0.292 0.45 -0.584 0.673 -0.848 c 0.075 -0.088 0.147 -0.173 0.213 -0.253 c 0.561 -0.679 0.985 -1.32 0.985 -2.304 c 0 -2.06 -1.637 -3.75 -4 -3.75 Z M 5.75 12 h 4.5 a 0.75 0.75 0 0 1 0 1.5 h -4.5 a 0.75 0.75 0 0 1 0 -1.5 Z M 6 15.25 a 0.75 0.75 0 0 1 0.75 -0.75 h 2.5 a 0.75 0.75 0 0 1 0 1.5 h -2.5 a 0.75 0.75 0 0 1 -0.75 -0.75 Z",
  caution:
    "M 4.47 0.22 A 0.749 0.749 0 0 1 5 0 h 6 c 0.199 0 0.389 0.079 0.53 0.22 l 4.25 4.25 c 0.141 0.14 0.22 0.331 0.22 0.53 v 6 a 0.749 0.749 0 0 1 -0.22 0.53 l -4.25 4.25 A 0.749 0.749 0 0 1 11 16 H 5 a 0.749 0.749 0 0 1 -0.53 -0.22 L 0.22 11.53 A 0.749 0.749 0 0 1 0 11 V 5 c 0 -0.199 0.079 -0.389 0.22 -0.53 Z m 0.84 1.28 L 1.5 5.31 v 5.38 l 3.81 3.81 h 5.38 l 3.81 -3.81 V 5.31 L 10.69 1.5 Z M 8 4 a 0.75 0.75 0 0 1 0.75 0.75 v 3.5 a 0.75 0.75 0 0 1 -1.5 0 v -3.5 A 0.75 0.75 0 0 1 8 4 Z m 0 8 a 1 1 0 1 1 0 -2 a 1 1 0 0 1 0 2 Z",
};
분명 이것보다 더 좋은 방법이 있을 것 같은데...

이렇게 해서 커스텀 플러그인을 만드는 과정까지 소개했습니다. 저는 추가로 2개의 플러그인을 더 만들어 총 3개의 커스텀 플러그인을 제작했고, 그 중에 현재 2개를 사용하고 있습니다. 나중에 코드 복사 버튼을 위해 한개 더 만들 계획에 있습니다.

마무리

3편에 이어진 마크다운 불러오기편이 마무리 되었습니다. 블로그를 제작중인 분들이 이 글을 보고 마크다운으로 작성된 문서를 원하는 스타일로 꾸미는데 도움이 되었으면 좋겠네요.
다음 포스트는 블로그 포스트들을 필터링하는 Filter 제작편으로 업로드 할 예정이니 많은 관심 부탁드려요~