Engineering note · Vue/Nuxt

Nuxt Content v2에서 MDC 커스텀 컴포넌트 렌더링하기

2025년 03월 28일이현수
태그nuxtnuxt-contentvue3markdownmdc

Nuxt Content v2의 Markdown AST에 CardLink 같은 Vue 컴포넌트를 매핑하고, MDC의 슬롯·인라인 props·YAML props 중 유지보수하기 좋은 작성 방식을 정리한다.

Obsidian에서 작성한 기술 문서를 Nuxt Content v2 블로그로 옮기면서 단순 Markdown만으로 표현하기 어려운 요소가 생겼다. 링크의 제목, 설명, 파비콘과 대표 이미지를 한 묶음으로 보여 주는 CardLink가 대표적이었다.

처음에는 fenced code block 안의 텍스트를 직접 읽어 카드로 바꾸려고 했다. 하지만 Markdown 파서가 일반 문자열과 링크를 서로 다른 VNode로 나누기 때문에 슬롯의 자식 구조를 평탄화하는 코드가 복잡해졌다. Nuxt Content가 제공하는 MDC(Markdown Components) 구문으로 컴포넌트와 데이터를 명시하는 편이 구조가 단순했다.

Markdown이 AST의 컴포넌트 노드로 바뀌는 과정

Nuxt Content v2의 queryContent()는 Markdown을 그대로 반환하지 않고 렌더링 가능한 AST로 변환한다. MDC 블록의 이름은 AST 노드의 tag가 된다.

md
::cardlink
CardLink 내부에 전달할 내용
::

아래 화면에서는 ::cardlink 블록이 tag: "cardlink"인 element로 파싱된 것을 확인할 수 있다. 같은 문서 안의 pre, p, ol 같은 Markdown 요소와 동일한 children 배열에 들어간다.

Nuxt Content AST에서 cardlink 컴포넌트 노드를 확인한 화면

이 단계에서는 cardlink라는 이름만 AST에 존재한다. 실제 Vue 컴포넌트로 렌더링하려면 ContentRendererMarkdown에 이름과 컴포넌트의 매핑을 전달해야 한다.

ContentRendererMarkdown에 컴포넌트 매핑하기

Nuxt Content v2에서 기본 슬롯을 직접 구성하고 ContentRendererMarkdown을 사용한다면 components prop을 함께 넘긴다.

vue
<script setup lang="ts">
import CardLink from '~/components/content/CardLink.vue'

const route = useRoute()
const { data: article } = await useAsyncData(
  `article:${route.path}`,
  () => queryContent(route.path).findOne(),
)

const contentComponents = {
  cardlink: CardLink,
}
</script>

<template>
  <ContentRenderer v-if="article" :value="article">
    <ContentRendererMarkdown
      :value="article"
      :components="contentComponents"
    />
  </ContentRenderer>
</template>

contentComponentscardlink 키가 앞에서 확인한 AST의 tag와 일치해야 한다. 매핑이 빠지면 Markdown에는 노드가 있어도 기대한 CardLink UI로 렌더링되지 않는다.

본문은 slot으로 전달하기

MDC 블록의 시작과 끝 사이에 작성한 Markdown은 기본 슬롯으로 전달된다. 단순한 안내 상자처럼 내부의 서식을 그대로 보여 주려면 컴포넌트에서 슬롯을 렌더링하면 된다.

md
::notice-card
이 안에서는 **굵은 글씨**와 [링크](https://example.com)를 사용할 수 있다.
::
vue
<!-- components/content/NoticeCard.vue -->
<template>
  <aside class="notice-card">
    <ContentSlot :use="$slots.default" unwrap="p" />
  </aside>
</template>

Nuxt Content v2에서는 ContentSlot으로 Markdown 슬롯을 렌더링하고 unwrap="p"로 불필요한 바깥 문단을 제거할 수 있다. 슬롯은 설명문처럼 구조를 유지해야 하는 콘텐츠에 적합하다.

구조화된 값은 YAML props로 전달하기

CardLink는 본문보다 url, title, description, host, favicon, image처럼 이름이 정해진 값이 중요하다. 이 데이터를 슬롯의 VNode에서 다시 문자열로 조립하면 링크가 <a> 노드로 분리되는 경우까지 처리해야 한다.

MDC는 중괄호를 사용하는 인라인 props를 지원한다.

md
::cardlink{url="https://example.com" title="Example" host="example.com"}
::

값이 많아지면 한 줄이 길어지므로 YAML props 방식이 읽고 수정하기 쉽다.

md
::cardlink
---
url: https://example.com/
title: "Example"
description: "CardLink 컴포넌트에 전달할 설명"
host: example.com
favicon: https://example.com/favicon.ico
image: https://example.com/og.png
---
::

컴포넌트에서는 일반 Vue props로 받는다.

vue
<script setup lang="ts">
defineProps<{
  url: string
  title: string
  description?: string
  host?: string
  favicon?: string
  image?: string
}>()
</script>

<template>
  <a :href="url" class="card-link" target="_blank" rel="noreferrer">
    <img v-if="image" :src="image" alt="" class="card-link__image" />
    <span class="card-link__body">
      <strong>{{ title }}</strong>
      <span v-if="description">{{ description }}</span>
      <small v-if="host">{{ host }}</small>
    </span>
  </a>
</template>

이 방식에서는 파서가 props를 구성하므로 컴포넌트가 슬롯의 내부 VNode 형태에 의존하지 않는다. URL 안의 :나 제목의 특수 문자가 YAML 해석을 방해할 수 있으므로 값이 복잡하면 따옴표로 감싸는 편이 안전하다.

기존 Markdown 표현을 바꿀지 결정하기

Obsidian 플러그인이 생성한 fenced cardlink 블록이나 callout은 MDC와 문법이 다르다. 선택지는 두 가지다.

  • 원고를 발행할 때 fenced block을 MDC로 변환한다.
  • Markdown의 기본 태그를 유지해야 한다면 Prose 컴포넌트나 Content transformer에서 변환한다.

CardLink처럼 독립된 블록은 발행 단계에서 MDC로 변환하는 방법이 단순하다. 반면 모든 링크나 문단의 표현을 바꾸려면 해당 HTML 태그를 담당하는 Prose 컴포넌트를 재정의하는 편이 낫다. 문서 전체의 파싱 결과를 컴포넌트 안에서 다시 해석하는 방식은 AST 구조가 바뀔 때 깨지기 쉬우므로 마지막 수단으로 남긴다.

참고한 페이지

좋아요와 댓글

댓글 남기기

댓글 0개

댓글을 불러오는 중입니다.