현록

marked.js renderer custom 하기

Markdown을 HTML로 변환할 때 marked.js를 사용할 수 있다.
기본 변환 결과만으로 충분한 경우도 있지만, 이미지 태그나 링크 태그의 HTML을 직접 조정해야 할 때가 있다.

설치, marked.parse(), gfm, breaks, HTML 정화부터 시작하려면 marked.js 사용법과 기본 설정을 먼저 보면 좋다.

예를 들어 Markdown 이미지가 본문 너비를 넘지 않게 만들거나, 외부 링크에 target="_blank"rel="noopener noreferrer"를 붙이고 싶을 수 있다.
이럴 때 사용할 수 있는 확장 지점이 renderer다.

marked renderer custom은 Markdown 문법을 바꾸는 작업이 아니다.
Markdown이 토큰으로 해석된 뒤, 각 토큰을 어떤 HTML 문자열로 만들지 바꾸는 작업에 가깝다.

Renderer의 역할

marked.js는 Markdown 문자열을 바로 HTML로 바꾸는 것처럼 보인다.
내부 흐름은 크게 보면 Markdown을 토큰으로 만들고, 그 토큰을 renderer가 HTML로 출력하는 구조다.

Markdown source -> tokens -> renderer -> HTML

marked 공식 문서 기준으로 renderer는 주어진 token의 HTML 출력을 정의한다.
marked.use({ renderer })로 특정 token 타입의 기본 출력 방식을 덮어쓸 수 있다.

import { marked } from 'marked'

marked.use({
  renderer: {
    heading({ tokens, depth }) {
      const text = this.parser.parseInline(tokens)
      return `<h${depth}>${text}</h${depth}>`
    },
  },
})

대부분의 경우 전체 renderer를 새로 만드는 것보다 필요한 메서드만 덮어쓰는 편이 낫다.
이미지 출력만 바꾸고 싶다면 image만, 링크 출력만 바꾸고 싶다면 link만 커스텀한다.

image 렌더러 커스텀

이미지 태그를 커스텀하려면 image renderer를 정의한다.
최신 marked 타입에서는 문자열 인자를 여러 개 받는 방식보다 token 객체를 받는 방식으로 이해하는 편이 좋다.

import { marked, type Tokens } from 'marked'

function escapeAttribute(value: string) {
  return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}

marked.use({
  renderer: {
    image(token: Tokens.Image) {
      const src = escapeAttribute(token.href)
      const alt = escapeAttribute(token.text)
      const title = token.title ? ` title="${escapeAttribute(token.title)}"` : ''

      return `<img src="${src}" alt="${alt}"${title} style="max-width: 100%;" />`
    },
  },
})

이렇게 하면 Markdown 이미지가 HTML <img> 태그로 변환될 때 원하는 속성을 붙일 수 있다.

![대체 텍스트](/image.png "이미지 제목")
<img src="/image.png" alt="대체 텍스트" title="이미지 제목" style="max-width: 100%;" />

이미지에는 의미 있는 alt 값을 넣는 편이 좋다.
alt는 접근성뿐 아니라 이미지가 로드되지 않았을 때의 대체 정보로도 중요하다.

link 렌더러 커스텀

링크 태그도 비슷하게 커스텀할 수 있다.
외부 링크만 새 탭으로 열고, 내부 링크는 그대로 두는 패턴이 흔하다.

import { marked, type Tokens } from 'marked'

function isExternalUrl(href: string) {
  return /^https?:\/\//.test(href)
}

marked.use({
  renderer: {
    link(token: Tokens.Link) {
      const href = escapeAttribute(token.href)
      const label = this.parser.parseInline(token.tokens)
      const title = token.title ? ` title="${escapeAttribute(token.title)}"` : ''

      if (!isExternalUrl(token.href)) {
        return `<a href="${href}"${title}>${label}</a>`
      }

      return `<a href="${href}"${title} target="_blank" rel="noopener noreferrer">${label}</a>`
    },
  },
})

외부 링크에 target="_blank"만 붙이고 rel을 빼면 보안상 아쉬운 부분이 생길 수 있다.
새 탭으로 열리는 링크에는 보통 rel="noopener noreferrer"를 함께 붙인다.

내부 링크는 현재 탭에서 이동하는 편이 자연스럽다.
블로그 글 사이의 링크까지 모두 새 탭으로 열면 사용 흐름이 끊길 수 있기 때문이다.

HTML sanitize 주의점

renderer는 HTML 문자열을 직접 반환한다.
그래서 사용자 입력이 섞이면 XSS 위험을 신경 써야 한다.

marked 공식 문서도 marked가 출력 HTML을 sanitize하지 않는다고 안내한다.
신뢰할 수 없는 Markdown을 처리한다면 변환 결과에 DOMPurify, sanitize-html 같은 HTML 정화 도구를 적용해야 한다.

import { marked } from 'marked'
import sanitizeHtml from 'sanitize-html'

const dirtyHtml = marked.parse(markdown)
const safeHtml = sanitizeHtml(dirtyHtml)

renderer에서 속성 값을 만들 때도 escape 처리가 필요하다.
예를 들어 이미지 alt나 링크 title에 큰따옴표가 들어오면 HTML 속성이 깨질 수 있다.

function escapeAttribute(value: string) {
  return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}

서비스에서 허용할 태그와 속성도 명확히 정해야 한다.
style 속성을 허용할지, targetrel을 허용할지, 이미지 src를 어떤 경로로 제한할지 같은 기준이 필요하다.

커스텀 위치

marked.use()는 애플리케이션 초기화 지점에서 한 번만 호출하는 편이 좋다.
컴포넌트 렌더링 함수 안에서 매번 호출하면 같은 확장이 반복 등록될 수 있다.

import { marked } from 'marked'

const renderer = {
  image(token) {
    return `<img src="${token.href}" alt="${token.text}" style="max-width: 100%;" />`
  },
}

marked.use({ renderer })

반복 실행될 가능성이 있다면 별도 Marked 인스턴스를 만들어 사용하는 방식도 고려할 수 있다.
전역 marked 설정을 여러 곳에서 바꾸면 어느 renderer가 최종 적용되는지 추적하기 어려워진다.

정리

marked.js renderer는 Markdown token을 HTML 문자열로 바꾸는 단계다.
이미지, 링크, heading 같은 출력 결과를 바꾸고 싶을 때 renderer를 커스텀한다.

이미지 렌더러에서는 src, alt, title 같은 속성을 안전하게 만든다.
링크 렌더러에서는 내부 링크와 외부 링크를 나누고, 외부 링크에는 target="_blank"rel="noopener noreferrer"를 함께 고려한다.

renderer는 HTML을 직접 반환하므로 보안 기준도 같이 봐야 한다.
신뢰할 수 없는 Markdown이라면 marked 변환 결과를 그대로 렌더링하지 말고 sanitize 단계를 둔다.

참고 자료

관련 포스트
TypeScript type과 interface 차이 thumbnail
TypeScript type과 interface 차이
TypeScript의 type과 interface 차이를 객체 모델링, 유니온, 선언 병합, extends와 교차 타입의 충돌 처리, 실무 선택 기준으로 나누어 정리합니다.
JavaScript ==와 === 차이 thumbnail
JavaScript ==와 === 차이
JavaScript ==와 ===의 차이를 암시적 타입 변환, null과 undefined, boolean과 문자열, 객체 참조, NaN, 비교 연산자 선택 기준으로 정리합니다.
marked.js 사용법과 기본 설정 thumbnail
marked.js 사용법과 기본 설정
marked.js 사용법을 초보자 기준으로 정리합니다. marked.parse와 parseInline, gfm과 breaks 옵션, 브라우저 출력, HTML sanitize 주의점까지 함께 봅니다.
JavaScript var let const 차이 thumbnail
JavaScript var let const 차이
JavaScript var, let, const 차이를 함수와 블록 스코프, 재할당과 재선언, 호이스팅과 TDZ, 반복문 closure, 객체 변경 기준으로 정리합니다.
URL path parameter와 query string 차이 thumbnail
URL path parameter와 query string 차이
URL path parameter와 query string의 차이를 초보자 기준으로 정리합니다. 리소스 식별자, 조회 조건, URLSearchParams, 인코딩, API 설계 기준을 함께 봅니다.
쿠키와 localStorage 차이 thumbnail
쿠키와 localStorage 차이
쿠키와 localStorage의 차이를 초보자 기준으로 정리합니다. 서버 자동 전송, JavaScript 접근, 만료 시간, HttpOnly, Secure, SameSite, 로그인 상태 저장 기준을 함께 봅니다.
script async와 defer 차이 thumbnail
script async와 defer 차이
HTML script 태그의 기본 동작, async와 defer의 다운로드 방식, 실행 순서, DOMContentLoaded와의 관계를 초보자 기준으로 정리합니다.
CORS 에러와 해결 기준 thumbnail
CORS 에러와 해결 기준
브라우저의 Same-Origin Policy, CORS 응답 헤더, preflight, 서버에서 해결해야 하는 이유를 초보자 기준으로 정리합니다.
fetch와 axios 차이 thumbnail
fetch와 axios 차이
fetch와 axios의 차이를 초보자 기준으로 정리합니다. 설치 여부, JSON 처리, HTTP 에러 처리, timeout, interceptor, 언제 어떤 방식을 쓰면 좋은지 함께 봅니다.
JavaScript map과 forEach 차이 thumbnail
JavaScript map과 forEach 차이
JavaScript의 map과 forEach 차이를 반환값, 새 배열, 부수 효과, 원본 변경, 비동기 콜백, Promise.all 사용 기준으로 나누어 정리합니다.
Vue 3 입문기 thumbnail
Vue 3 입문기
Vue의 메인 버전이 3가 된지 꽤 됐다. 작성 당시에는 Nuxt 3가 RC 단계였기 때문에 실서비스에 바로 적용하기에는 조심스러웠다. 그래서 궁금하고 심심하던 참에 간단한 Todo App을 만들어봤다.
가독성 있게 상수 넣기 thumbnail
가독성 있게 상수 넣기
오늘 회사 동료의 PR 리뷰 과정에서 좋은 기능을 공유해주셔서 TIL로 남겨봅니다. 아래와 같이 상수에 언더바(_)로 콤마처럼 구분을 시켜줄 수 있습니다. 앞으로 깔끔하고 좋은 코드를 작성하기 위해 자주 사용해야겠습니다 :)
TS에서 generic optional 하게 설정하기 thumbnail
TS에서 generic optional 하게 설정하기
오늘 next에서 `getStaticProps`와 `getLayout` 패턴을 함께 사용할 때, typescript generic을 넘겨주는 작업을 하고 있었는데, 기본값이 없다보니 기존 코드에 에러가 발생했었다. 이를 해결하기 위해 찾아보니 단순히 아래 예시처럼 `= {}`을 추가해주면 해결된다고 한다.
Backend에서 API Response가 snake_case인 경우엔? thumbnail
Backend에서 API Response가 snake_case인 경우엔?
안녕하세요. 프론트엔드 개발자의 경우, 가끔 백엔드의 API Response 값이 snake_case일 경우 어떻게 관리할지에 대해 고민에 빠지게 됩니다. 저도 오늘 같은 상황을 겪게 되었는데,  이번엔 네이밍 컨벤션을 맞춰주기로 했습니다. 컨벤션을 맞추는 데에는 여러가지 방법이 있겠지만, 고민 끝에 저는 axios의 interceptors를 통해 해결을 해보았습니다.
Promise 다루기 (feat. 병렬실행, 순차실행) thumbnail
Promise 다루기 (feat. 병렬실행, 순차실행)
오늘은 Promise를 통해 구문을 동기 처리 할 때, 여러 Promise들을 다루는 법을 소개해보겠습니다. Javscript를 작성하다 보면, 가끔 여러 Promise들을 다룰 때가 있습니다. 필자도 Nodejs 서버에서 동시에 여러 쿼리를 실행할 때 자주 마주쳤었는데요. 오늘은 어떻게 하면 Promise들을 유연하게 다룰 수 있는지 알아보겠습니다. 시작하기 앞서, 네 가지 Promise를 선언하고, 그들을 하나의 Array에 묶어보겠습니다.