본문으로 건너뛰기
언어

@plott/webkit-markdown0.4.0

마크다운을 안전하게 그린다 — GFM, 원문 HTML 은 지우거나 글자로(통과 없음), 링크·이미지 스킴/호스트 규칙, 한글이 살아 있는 제목 id·목차, 길이·깊이 한도. 서버 컴포넌트에서 React 요소로, 또는 HTML 문자열로

설치

pnpm
pnpm add @plott/webkit-markdown@0.4.0

진입점(exports)

import타입파일
@plott/webkit-markdown./dist/index.d.ts./dist/index.js
@plott/webkit-markdown/react./dist/react/index.d.ts./dist/react/index.js
@plott/webkit-markdown/html./dist/html.d.ts./dist/html.js

사용자·관리자가 쓴 마크다운(공지, FAQ, 블로그 글, AI 답변)을 안전하게 그리는 패키지입니다. 읽는 사람은 Next.js 서버 컴포넌트(RSC)나 HTML 문자열로 마크다운을 보여 주는 프론트엔드 개발자입니다.

파서는 unified(remark-parse + remark-gfm)이고, 결과 트리(hast)를 rehype-sanitize 로 거른 뒤 링크·이미지·제목 규칙을 얹습니다. @plott/webkit-sanitize(HTML 문자열을 거르는 패키지)와 주소 규칙은 같지만, 이 패키지는 트리 단계에서 같은 규칙을 다시 구현해 서로 가져오지 않습니다.

진입점

import어디서주요 export
@plott/webkit-markdown서버·브라우저·Node 어디서나parseMarkdown, markdownToText, excerpt, MarkdownLimitError, RESERVED_HEADING_IDS, MarkdownOptions, TocItem
@plott/webkit-markdown/html서버·브라우저·NoderenderHtml (HTML 문자열)
@plott/webkit-markdown/react서버 컴포넌트·브라우저 (훅 없음, "use client" 불필요)Markdown, renderMarkdown, MarkdownComponents

react 는 /react 를 쓸 때만 필요합니다(선택 peer, ^19.3). 주소 판정 규칙(javascript: 같은 스킴 거부, 호스트 정규화, rel 토큰)은 @plott/webkit-sanitize/url 이 한 곳에서 갖고, 이 패키지는 그것을 가져다 씁니다.

붙이기

서버 컴포넌트에서는 Markdown 을 바로 그립니다.

tsx
import { Markdown } from "@plott/webkit-markdown/react";import type { ComponentProps } from "react";
declare const notice: { body: string };declare const MyLink: (props: ComponentProps<"a">) => React.ReactNode;declare const MyImage: (props: ComponentProps<"img">) => React.ReactNode;
export const view = <Markdown source={notice.body} headings={{ shift: 1 }} components={{ a: MyLink, img: MyImage }} />;

목차·요약까지 필요하면 한 번 파싱해서 나눠 씁니다.

ts
import { excerpt, parseMarkdown } from "@plott/webkit-markdown";import { renderMarkdown } from "@plott/webkit-markdown/react";
declare const post: { body: string };
const parsed = parseMarkdown(post.body, { images: { hosts: ["cdn.example.com"] } });parsed.toc;parsed.text;parsed.words;const body = renderMarkdown(parsed);
export const description = excerpt(post.body, 120);
값뜻
parsed.toc[{ depth, id, text, children }]
parsed.text보이는 글자만 모은 평문
parsed.words낱말 수
renderMarkdown(parsed)다시 파싱하지 않고 그립니다
excerpt(post.body, 120)meta description 용 요약

HTML 문자열이 필요하면 renderHtml 을 씁니다. "strict" 는 이미지 없음, 원문 HTML 지움입니다.

ts
import { renderHtml } from "@plott/webkit-markdown/html";declare const comment: { body: string };
export const html = renderHtml(comment.body, "strict");

옵션 자리에는 객체나 프리셋 이름("strict", "trusted")을 넣습니다.

옵션기본값뜻
preset없음"strict": 이미지 없음·원문 HTML 지움. "trusted": 이미지는 어느 호스트든(https, 주소 검사는 그대로)·원문 HTML 은 글자로. 개별 옵션이 먼저입니다
gfmtrue표·취소선·할 일 목록·자동 링크·각주. 취소선은 ~~글~~ 만(10~20 은 그대로)
html"escape"원문 HTML 을 글자로 보여 줍니다. "drop" 은 지웁니다. 통과시키는 값은 없습니다
maxLength / truncate200_000 / false글자 수 한도. 넘으면 파싱 전에 던지고, truncate: true 면 앞부분만 씁니다
maxDepth32인용·목록·강조의 중첩 한도
maxNodes50_000노드 수 한도(거대한 표)
links.protocolshttp https mailto tel허용 스킴. javascript·vbscript·data·file·blob 은 적어도 막습니다
links.external{ rel: "noopener noreferrer", target: null }바깥 링크의 rel·target
links.isExternal / links.rewrite없음바깥 링크 판정 바꾸기 / 주소 바꾸기(null 이면 링크를 풀고 글자만). rewrite 는 # 접두사가 붙기 전의 원래 href 를 받고, 돌려준 값이 #… 이면 그 뒤에 접두사가 붙습니다
images.hosts"none"바깥 이미지를 받을 호스트(배열은 정확히 일치), "any", "none". 상대 주소 이미지는 늘 통합니다
images.allowHttp / loading / rewritefalse / "lazy" / 없음http 이미지 허용 / 로딩 / 이미지 프록시용 주소 바꾸기
images객체false 면 이미지를 모두 대체 글(alt)로 바꿉니다
headings.idstrue제목 id. 함수 (글자, 순번) => id 도 됩니다. false 면 id 와 목차 링크 없음
maxInlineMarks2000한 문단(코드·표 줄 제외)의 인라인 표지(*·_·~·백틱·[·]·<) 한도. 문서 전체로는 이 한도 문단 8개 분량까지. 넘으면 파싱 전에 limit: "inline"
maxParseMs2000파싱 단계(파싱·변환)마다 재는 시간 한도(ms). 넘으면 뒷단으로 가지 않고 limit: "time"
headings.idPrefix"user-content-"제목 id 와 각주 id(fn-1·fnref-1·footnote-label) 앞에 붙일 글자. 문서 안 조각 링크([설치](#설치))의 href 에도 붙어 제목 id 와 맞습니다(이미 접두사로 시작하는 조각은 그대로). "" 는 직접 고르는 선택이며 그때는 조각 링크를 바꾸지 않고, 전역 이름 거부 목록이 돕니다
headings.reservedIdsRESERVED_HEADING_IDS(location·window·constructor … 전역 이름)제목 id 가 되면 -1 을 붙여 비켜 가는 이름. 주면 기본 목록을 대신하고, 접두사가 있어도 적용합니다. 기본 목록은 RESERVED_HEADING_IDS 로 내보냅니다
headings.shift / maxTocDepth0 / 6제목 단계 낮추기(h1→h2) / 목차 깊이(화면에 나가는 단계 기준)
footnotes영어{ label, backLabel } 각주 영역 글. 화면 언어에 맞게 주세요

parseMarkdown 은 { hast, toc, text, words } 를, markdownToText 는 평문(엔티티가 풀리고 마크업이 없음, 원문 HTML 은 지움)을, excerpt(src, 길이 | { length, ellipsis, markdown }) 는 요약을 돌려줍니다.

기본으로 지키는 것

각 항목은 test/ 의 양성:·음성: 시험으로 확인합니다. 시간 한도는 시계를 고정해(performance.now 대체) 시험하고, 나머지 한도는 파서·변환 단계가 시작됐는지 세어 시험합니다.

  • javascript:·vbscript:·data: 링크·이미지가 나가지 않습니다. &#x6A;avascript:, 탭·개행(jav&#x09;ascript:), 앞뒤 제어 문자, 대소문자 섞기, 참조 링크([a][r]), 자동 링크(<javascript:…>)로 숨겨도 막습니다. 검사한 주소와 내보내는 주소가 같도록 브라우저가 URL 을 읽는 방식대로 정리한 값을 씁니다. 막힌 링크는 글자만 남고, 이미지는 대체 글이 남습니다.
  • 프로토콜 상대 주소(//host, \\host)는 링크·이미지에서 받지 않습니다. 현재 페이지 스킴을 물려받는 외부 주소이기 때문입니다.
  • 원문 HTML 은 절대 통과하지 않습니다. <script>, <img onerror>, <iframe>, <svg onload>, 주석 속임수가 모두 글자로 보이거나(기본) 지워집니다. 주석(<!-- -->)은 어느 쪽이든 보이지 않습니다. 파서 단계에서 걸러내고, 한 번 더 rehype-sanitize 로 마크다운이 만드는 요소·속성만 남깁니다. style·on*·임의 id·class 는 나가지 않습니다(코드 블록의 language-* 와 GFM 클래스만).
  • 바깥 링크에는 rel="noopener noreferrer" 가 붙습니다. target="_blank" 를 쓰면 rel 을 비우라고 해도 noopener·noreferrer 가 반드시 붙습니다(탭 하이재킹 방지).
  • 바깥 이미지는 기본으로 받지 않습니다. 추적 픽셀과 혼합 콘텐츠(https 페이지의 http 이미지)를 막습니다. 호스트는 정확히 일치해야 하고(cdn.ok.com.evil.com, cdn.ok.com@evil.com 은 통하지 않음), 바깥 이미지에는 referrerpolicy="no-referrer"·loading="lazy" 가 붙습니다.
  • 제목 id 는 비지 않고 겹치지 않으며 한글이 살아 있습니다. github-slugger 로 안녕 하세요 → 안녕-하세요, 중복은 -1, -2, 글자가 하나도 안 남는 제목(!!!, 이모지)은 section-n 입니다. 제목 id 는 기본으로 user-content- 로 시작하므로(# gtag → user-content-gtag) window.<id> 로 전역(gtag·dataLayer·location)을 덮는 DOM clobbering 이 생기지 않습니다. 접두사를 "" 로 직접 비우면 location·document·name 같은 전역 이름과 같은 id 는 만들지 않지만(location-1) 그 목록은 모든 전역을 다 덮지 못하니, 비우는 쪽이 책임을 집니다. 제목 id 와 각주 id(user-content-fn-1)는 같은 중복 검사를 거쳐 서로 겹치지 않습니다. 본문에서 id 를 심을 길은 없습니다.
  • 병적인 입력은 비싼 단계 전에 MarkdownLimitError 로 끊깁니다. 5MB 입력은 파서를 시작하기 전에 길이만 보고 실패합니다. 인용 중첩(maxDepth)도 파서 전에, 목록 중첩과 노드 수(maxNodes)는 파싱 직후 변환 단계 전에 끊습니다. 인라인 해석은 한 문단의 표지 수에 제곱으로 느려지므로(a*b*c 반복, _a_ 반복), 원문을 문단 단위로 훑어 표지가 maxInlineMarks(2000)를 넘는 문단을 파싱 전에 limit: "inline" 으로 거부합니다. 울타리·들여쓴 코드 블록과 표(줄마다)는 인라인 해석을 하지 않아 세지 않으므로, 코드가 긴 글이 거부되지 않습니다. 코드로 위장해 세기를 피하는 모양(문단 속 들여쓴 줄, 닫히지 않는 울타리, 칸 수가 다른 표 머리)은 문단으로 셉니다. maxParseMs(2000)는 느린 입력이 뒷단까지 CPU 를 쓰지 않게 파싱 단계와 변환 단계가 끝날 때마다 경과 시간을 재는 안전망입니다. 한 단계 안에서는 끊을 수 없어서, 최악의 시간을 정하는 것은 표지 한도입니다.
  • 링크·이미지 주소에 사용자 정보(https://user:pw@host/)가 있으면 받지 않습니다. 모든 프리셋에서 링크는 글자만, 이미지는 대체 글만 남습니다.
  • 서버와 브라우저가 같은 결과를 냅니다. /react 는 훅·상태·useId·난수·브라우저 전역을 쓰지 않아, 같은 마크다운은 renderToString·renderToReadableStream·브라우저용 react-dom/server 에서 글자 하나 다르지 않습니다.

그 밖

  • 한도 오류는 MarkdownLimitError(limit: "length" | "depth" | "nodes" | "inline" | "time")입니다. 잘못된 옵션은 @plott/webkit-markdown: 로 시작하는 TypeError 입니다. 렌더 중에 던지므로 호출 쪽에서 error boundary 나 try/catch 로 받으세요.
  • 표 정렬은 HTML 문자열에서는 align 속성, React 요소에서는 style="text-align:…" 로 나갑니다(hast-util-to-jsx-runtime 의 변환).
  • text·markdownToText 는 코드 블록 글자를 포함하고 이미지 대체 글·체크박스·각주는 뺍니다.
  • 단어 수와 요약은 Intl.Segmenter 를 씁니다(로케일 und, 호스트 로케일에 기대지 않음).
  • 제목 id 에 함수를 주면(headings.ids) 접두사가 붙고, 같은 중복 검사를 거칩니다.
  • # 로만 시작하는 링크만 접두사를 받습니다(#설치 → #user-content-설치, 퍼센트 인코딩 형태도 같은 id 를 가리킵니다). ../format#사용·./x.md#a 처럼 경로가 앞선 링크는 건드리지 않으므로, 다른 문서의 제목으로 가는 조각은 앱의 links.rewrite 가 맡고 그 대상 제목의 id 에도 접두사가 붙어 있다는 점을 계산에 넣어야 합니다.