Markdown이 약간 부족할 때

일상적인 글에서는 CommonMark도 충분한 기능을 제공한다. 어느 날, Markdown 문서에 <dl>노트 박스(클래스를 지정한 <div>)를 가벼운 구문으로 넣을 방법이 필요해졌다. Markdown에도 HTML 태그를 직접 삽입할 수 있는 환경이지만, 글을 쓰는데 마크업을 하고 싶지는 않기에 굳이 컴파일을 하면서 Markdown을 쓰는 중이니, 그런 일은 피하고 싶었다.

- : JavaScript
  - 가벼운 인터프리터 혹은 *just-in-time* 컴파일
    프로그래밍 언어로, 일급 함수를 지원합니다.

> [note]
> JavaScript는 웹 페이지를 위한 스크립트 언어로 잘
> 알려져 있지만, [Node.js](https://nodejs.org/)처럼
> 비 브라우저 환경에서도 사용하고 있습니다.

<dl>
  <dt>JavaScript</dt>
  <dd>
    가벼운 인터프리터 혹은 <i>just-in-time</i>
    컴파일 프로그래밍 언어로, 일급 함수를 지원합니다.
  </dd>
</dl>

<div class="notebox notebox-note">
  <p>
    JavaScript는 웹 페이지를 위한 스크립트 언어로 잘 알려져 있지만, <a href="https://nodejs.org/">Node.js</a>처럼 비
    브라우저 환경에서도 사용하고 있습니다.
  </p>
</div>
Markdown / HTML 코드의 비교

그러나 완전히 새로운 Markdown 구문을 만들기엔 파싱하기도 너무 어렵고, 힘들게 구현해봤자 에디터와 포매팅 지원이 제대로 되지 않아 제대로 만족할 수도 없을 것은 분명했다. 대신, 기존 구문을 특정한 패턴에 맞춰 입력하면 내가 원하는 HTML 코드가 나오도록 하는 것을 목표로 삼았다. 예컨대 블록 인용문을 미리 정한 문자열인 [note], [warn], [fatal] 중 하나로 시작하면, 각각 파란색/노란색/빨간색 노트 박스로 렌더링하는 것.

이 마음을 먹은 당시에도, 그리고 지금도 순수한 Markdown 문서가 아니라 MDX를 쓰고 있는데, MDX 컴파일러가 Markdown 구문을 remarkrehype로 변환한다는 사실까지만 알 수 있었다. 관련 패키지들이 작은 단위로 나눠져있어서 README를 읽어도 이해하기 힘들었고, 결국 시작을 하고 싶어도 방향부터가 모호했다. MDX React 컴포넌트를 넣을 수밖에.

# 헤딩

<Notebox type="note">블록 인용문 구문에 비하면 너무 귀찮다는 점을 참고하세요.</Notebox>

등등...

이것도 닫는 태그의 존재가 가벼움과는 거리가 멀었고, <dl>, <dt>, <dd>는 결국 모두 직접 입력해야 했다. 그래서, 사이트 코드 전체 개편과 함께 Markdown이 어디서 어떻게 처리되는지 제대로 알아보려고 했다.

AST

세상 모든 기능이 npm에 패키지로 존재한다고 하듯, Markdown을 HTML로 변환해주는 컴파일러도 존재한다. markdown-it이 대표적인가보다. 그러나 ― 사실 MDX를 쓰려면 선택지가 없었던 것에 가깝지만 ― 나는 remark를 바탕으로 컴파일러를 구성했다. remark 컴파일러의 특징은 파싱 후 바로 HTML로 컴파일하는 것이 아니라 AST를 출력하는 것이다.

AST는 콘텐츠의 구문 구조를 추상적으로 나타낸 트리인데, 실제 형태와 상관없이 구문에서 의미를 갖고 있는 부분만 포함하기 때문에 추상적이라고 부른다. AST로 표현된 콘텐츠를 읽을 땐 코드 스타일같은 세부 사항에는 신경 쓰지 않을 수 있다.

Prettier는 파일을 AST로 읽어서 서식은 다 날려버리고, 자체 규칙으로 다시 서식해 출력한다. AST 특징을 가장 잘 활용하는 예시가 아닐까.

function echo(x) {
  if (x) console.log(x);
}
function echo(x) {
  if (x) {
    console.log(x)
  }
}
같은 AST로 표현할 수 있는, 서로 다른 두 코드

Markdown 변환 과정

unist와 unified

AST는 개념이다. 따로 정해진 구조는 트리라는 것을 제외하면 없다. 반대로 말하면, 표현하는 내용의 종류가 Markdown, JavaScript, HTML 등 서로 달라도, 트리의 세부 구조가 같으면 비교, 순회, 문자열화 등 공통 작업이 가능하다. 이를 위해 AST의 기본적인 명세를 커뮤니티에서 정의했다. unist다. TypeScript를 접해본 적이 있으면 unist 인터페이스 명세를 쉽게 이해할 수 있다.

unified는 unist 트리에 대한 프로세서다. 프로세서는 먼저 소스를 파싱해 unist 명세에 맞는 AST를 생성하고, 트랜스포머로 트리를 순회하면서 조작한 후, 컴파일러로 결과를 출력한다.

파서를 지나 트랜스포머를 거쳐 컴파일러로

unist와 마찬가지로 unified 스스로는 파싱할 코드의 종류나 컴파일할 결과의 형태를 정의하지 않는다. 입력을 처리해서 출력하는 파이프라인을 정의할 뿐이다. 파서, 트랜스포머, 컴파일러는 프로세서에 플러그인으로 추가하게 된다.

파서와 컴파일러와 트랜스포머

나는 Markdown 코드를 AST로 읽어야 하므로 Markdown 파서가 필요하다. Markdown 지원은 remark의 영역으로, remark-parse가 파서 플러그인, remark-stringify가 컴파일러 플러그인이다. Markdown 파일을 HTML로 변환해 웹 페이지에 출력하는 것이기 때문에 컴파일러인 remark-stringify는 필요 없다.

그다음엔 AST를 HTML 코드로 컴파일해야 하며, unified의 HTML 지원은 rehype가 한다. remark와 마찬가지로 rehype-parse가 파서, rehype-stringify가 컴파일러다. HTML AST는 읽을지언정 소스 코드를 파싱할 일은 없으므로 컴파일러만 사용한다.

하지만 remark-parserehype-stringify는 호환되지 않는다. 모두 unified 생태계의 일원이지만, 전자는 unist 명세를 Markdown에 맞게 확장한 mdast 트리를 생성하고, 후자는 HTML 전용으로 확장한 hast 트리를 기대하기 때문. 따라서 mdast를 hast로 변환해주는 트랜스포머, remark-rehype가 필요하다.

{
  "type": "blockquote",
  "children": [
    {
      "type": "paragraph",
      "children": [
        {
          "type": "inlineCode",
          "value": "Hello, world!"
        }
      ]
    }
  ]
}
{
  "type": "element",
  "tagName": "blockquote",
  "children": [
    {
      "type": "element",
      "tagName": "p",
      "children": [
        {
          "type": "element",
          "tagName": "code",
          "children": [
            {
              "type": "text",
              "value": "Hello, world!"
            }
  // ...
"> `Hello, world!`"의 mdast와 hast 표현

트랜스포머 추가하기

const processor = unified()
  .use(remarkParse) // Parser
  .use(remarkRehype) // mdast -> hast
  .use(rehypeStringify) // Compiler
  .process(file)

가장 기본적인 형태의 CommonMark-HTML 프로세서는 위와 같다. 나는 AST를 바꿔야 하므로 새로운 트랜스포머를 만들어야 하고, 트랜스포머는 파서, 컴파일러와 마찬가지로 unified 플러그인이므로, unified 플러그인의 형태로 구현해야 한다.

플러그인 기본 형태

unified 트랜스포머 플러그인은 트리를 순회/조작하게 될 함수를 생성하는 팩토리다.

import type { Root as HastRoot } from 'hast'
import type { Root as MdastRoot } from 'mdast'
import type { Plugin } from 'unified'

const transformer: Plugin<
  /* 플러그인 옵션 매개변수 */ void[],
  /* AST 루트 타입, hast 트랜스포머라면 HastRoot */ MdastRoot
> = () => {
  return (tree) => {
    // 트리 순회하면서 바꾸기
  }
}

순회는 직접 구현할 수도 있지만, unist와 unified에 적었듯 unist 트리라면 종류에 상관하지 않고 사용할 수 있는 유틸리티 패키지가 다수 존재한다. 그중에 unist-util-visit이 깊이 우선 순회를 제공한다.

unist-util-visit의 구문
import { visit } from 'unist-util-visit'

visit(tree[, test], function callback(node, index, parent) {/* ... */}[, reverse])
tree

트리의 루트 노드를 지정한다. 플러그인 반환 함수가 매개변수로 받는 tree를 그대로 넘겨주면 된다.

test (선택)

필터를 지정한다. 이 필터를 통과해야 callback을 호출한다. unist-util-isis() 함수가 받는 test 매개변수와 같은 타입의 값을 지정하면 된다. 문자열을 지정하면, node.type === test인 노드가 통과하는데, TypeScript 사용 시 callbacknode 타입이 해당 타입에 대응하는 인터페이스로 알아서 추론되므로 유용하다.

callback

노드에 대한 콜백 함수를 지정한다.

node

순회 중인 노드.

index

노드가 부모의 자식 리스트에서 위치한 인덱스.

parent

노드의 부모 노드.

노드를 통째로 갈아치워야 할 경우 parent[index] = newNode를 해도 문제가 없으며, (callback이 아무것도 반환하지 않을 경우) 정상적으로 newNodechildren에 대해 순회를 계속 진행한다. parent.children의 길이를 바꿀 수도 있다.

반환하는 값에 따라 다음 동작이 달라지는데, 가능한 값은 다음과 같다.

true 또는 void (undefined)

순회를 계속 진행한다.

false

순회를 즉시 종료한다.

'skip'

이 노드의 자식, 또는 현재 노드를 교체한 경우 새로운 노드의 자식을 순회하지 않고, parent의 다음 자식으로 넘어간다.

number

이 노드의 자식, 또는 현재 노드를 교체한 경우 새로운 노드의 자식을 모두 순회한 후, parent의 다음 자식이 아니라 반환한 값의 인덱스에 위치한 자식을 순회한다. parent.children의 길이가 바뀌는 경우 유용하다.

['skip', number]

이 노드의 자식, 또는 현재 노드를 교체한 경우 새로운 노드의 자식을 순회하지 않고, parent의 다음 자식이 아니라 반환한 값의 인덱스에 위치한 자식을 순회한다. parent.children의 길이가 바뀌는 경우 유용하다.

remark로 노트 박스 생성

블록 인용문이 [note], [warn], [fatal]로 시작한다면 노트 박스로 변환하는 것이 목표였다. 아래 코드가 그 플러그인의 예제다.

/*
찾아야 하는 노드 형태:
{
  type: 'blockquote',
  children: [
    {
      type: 'paragraph',
      children: [
        {
          type: 'text',
          value: '[note] 어쩌구 저쩌구'
        },
        // ...
      ]
    }
  ]
}
*/

import type { Paragraph, Parent, Root } from 'mdast'
import type { Plugin } from 'unified'

// CommonMark 노드에는 노트 박스를 나타내기에 적합한 노드 타입이 없으므로 하나 정의한다.
interface Notebox extends Parent {
  severity: 'note' | 'warn' | 'fatal'
  type: 'notebox'
}

const transformer: Plugin<void[], Root> = () => {
  return (tree) =>
    visit(tree, 'blockquote', (blockquote, index, parent) => {
      const [maybeParagraph] = blockquote.children
      // 블록 인용문 안의 텍스트는 문단(type: paragraph) 노드 안에 들어간다.
      if (maybeParagraph?.type !== 'paragraph') {
        return
      }

      const [maybeText] = (maybeParagraph as Paragraph).children
      // 문단 안에 텍스트 리터럴(type: text)이 들어가는 구조.
      if (maybeText?.type !== 'text') {
        return
      }

      // 텍스트 리터럴이 [note], [warn], [fatal] 중 하나로 시작하는지 확인.
      const severity = maybeText.value.match(/^\[(note|warn|fatal)\]/)?.[1] as 'note' | 'warn' | 'fatal' | undefined
      if (!severity) {
        return
      }

      // 텍스트 리터럴에서 [note], [warn], [fatal]을 제거.
      text.value = text.value.slice(severity.length + 2).trim()

      // 이 노드(블록 인용문)를 대체할 새로운 노드(노트 박스)를 선언.
      const noteboxNode: Notebox = {
        children: blockquote.children,
        severity,
        type: 'notebox',
      }

      // 부모의 자식들 중 이 노드를 대체한다.
      parent.children[index] = noteboxNode
    })
}

export { transformer as remarkNotebox }

TypeScript

TypeScript를 사용하는 경우 parent.children[index] = noteboxNode에서 타입 오류가 발생한다. mdast에는 { severity: ..., type: 'notebox' }인 타입이 없기 때문이다. 인식하도록 만드는 것은 어렵지 않다. 아래와 같이 mdast 모듈을 증강하면 된다.

// mdast.d.ts
import type { Parent } from 'mdast'

declare module 'mdast' {
  // 플러그인 파일에 선언했던 Notebox 타입을 옮겨온다.
  export interface Notebox extends Parent {
    severity: 'note' | 'warn' | 'fatal'
    type: 'notebox'
  }

  // mdast에서 새로운 노드 유형을 추가할 때 사용할 수 있는 타입에는
  // - BlockContent, 블록 레벨 노드. CSS에서 "블록 레벨 요소"라고 부르던 것들과 동일한 개념이다. 문단, 헤딩, 리스트 루트, 블록 인용문 등 자식을 가질 수 있는 노드.
  // - PhrasingContent, 인라인 레벨 노드. 마찬가지로 CSS "인라인 요소"와 동일하다. 인라인 코드, 강조, 취소선 등 자식을 가질 수 없는 리터럴 노드.
  // - 등등.

  // 노트 박스는 자식을 포함하는 블록 레벨 노드여야 하므로 BlockContent를 증강해야 한다.
  // BlockContent의 정의는 다음과 같다.
  //   type BlockContent = BlockContentMap[keyof BlockContentMap]
  // 따라서 직접 BlockContent를 수정하지 않고, BlockContentMap에 { [type]: NewNodeType }의 형태로 추가한다.
  type BlockContentMap = {
    notebox: Notebox
  }
}

보통 모듈 증강은 타입이 어딘가 잘못됐거나, 아예 타입을 제공하지 않는 모듈에 대해서 가끔 작성하는 경우가 대부분이라 내키지 않을 수도 있다. 그러나 타입의 선택지를 소비자가 추가하기 위한 증강도 자주 쓰인다. @types/mdast에 적힌 주석의 내용을 참고.

노트 박스의 hast 노드 생성

const processor = unified()
  .use(remarkParse) // Parser
  .use(remarkNotebox) // mdast blockquote -> mdast notebox
  .use(remarkRehype) // mdast -> hast
  .use(rehypeStringify) // Compiler
  .process(file)

Blockquote 노드를 성공적으로 변환했다! 그런데 노트 박스는 HTML에 div.notebox.notebox-${severity}로 출력하고 싶은데, remarkNotebox의 코드 어디에도 div 세 글자는 보이지 않는다.

mdast에서 hast로의 변환은 remark-rehype(remarkRehype)에서 일어나고, ...

그런데 remark-rehypetype: 'notebox'인 mdast 노드의 존재도 모르고, 당연히 변환법도 모른다. 노트 박스의 mdast 노드를 hast로 변환하려면 remark-rehype에게 방법을 알려줘야 한다.

노트박스의 변환 방법을 알려주려면

remark-rehype가 CommonMark 노드를 어떻게 변환하는지 확인해서 알아냈다. remark-rehype내부적으로 mdast-util-to-hasttoHast()를 사용한다. 이어서, mdast-util-to-hasttoHast()one(h, tree, null)으로 트리의 변환을 시작한다. 마지막으로 one()의 정의는 다음과 같다.

function one(h, node, parent) {
  const type = node && node.type
  let fn
  // ...
  fn = h.handlers[type]
  // ...
  return fn(h, node, parent)
}

즉, h.handlersmdast 노드의 type을 키로 하는 객체 맵이고, 그 값이 mdast 노드를 hast 노드로 변환하는 처리기 함수다. toHast() 본문으로 돌아가면 h = factory(...)고, factory() 본문을 보면 h.handlers는 내장 처리기들과, 옵션의 handlers를 합친 것임을 알 수 있다.

내장 처리기 함수 중 하나를 보면 처리기 작성 방법도 알아낼 수 있는데, h라는 함수 이름도 그렇고 전형적인 렌더 함수의 모습이다.

export function paragraph(h, node) {
  // all()은 순회 함수
  return h(node, 'p', all(h, node))
  //    태그 이름 ^    ^
  //       자식 리스트 |
}

종합해보면, 프로세서의 모습은 다음과 같다.

const processor = unified()
  .use(remarkParse)
  .use(remarkNotebox)
  .use(remarkRehype, {
    // remark-rehype에 제공하는 옵션 중 handlers는 그대로 mdast-util-to-hast로 전달된다.
    handlers: {
      // 세 번째에 '프롭'을 지정할 경우 자식 노드 리스트는 네 번째 매개변수.
      // type: 'notebox'로 선언했으므로 키를 notebox으로 함.
      notebox: (h, node) => h(node, 'div', { className: `notebox notebox-${node.severity}` }, all(h, node)),
    },
  })
  .use(rehypeStringify)
  .process(file)
> [note] 참고 등급 노트 박스

> [warn] 경고 등급 노트 박스
<div class="notebox notebox-note">
  <p>참고 등급 노트 박스</p>
</div>
<div class="notebox notebox-warn">
  <p>경고 등급 노트 박스</p>
</div>
Markdown 노트 박스와 컴파일 결과

unified 생태계는 입력 값이 바로 결과로 나타나는 것이 아니라 AST를 거쳐가기에 확장이 용이하다. remark를 쓰지 않았다면, 완전히 새로운 구문을 추가하려는 것이 아닌데도 파싱과 컴파일링 과정에 직접 관여해야 할 수도 있었다. 하지만 파서와 컴파일러가 하는 일은 하나도 신경 쓰지 않을 수 있었다.

AST를 직접 접해볼 기회가 많지 않을텐데, 어떤 식으로 활용하는지 배울 수 있는 시간이었다.

unified는 프로그래밍 언어 외에 (공백이 중요하므로 AST가 아니라 CST를 사용하는) 자연어 파서와 컴파일러도 가지고 있다. 부적절한 단어 경고단조로운 문장 피하기에 썼다고 한다.

마지막 업데이트:
이 페이지를 오프라인에서 볼 수 있습니다.

sorto.me

CC BY-SA 4.0