@plott/webkit-markdown0.4.0
마크다운을 안전하게 그린다 — GFM, 원문 HTML 은 지우거나 글자로(통과 없음), 링크·이미지 스킴/호스트 규칙, 한글이 살아 있는 제목 id·목차, 길이·깊이 한도. 서버 컴포넌트에서 React 요소로, 또는 HTML 문자열로
설치
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 | 서버·브라우저·Node | renderHtml (HTML 문자열) |
@plott/webkit-markdown/react | 서버 컴포넌트·브라우저 (훅 없음, "use client" 불필요) | Markdown, renderMarkdown, MarkdownComponents |
react 는 /react 를 쓸 때만 필요합니다(선택 peer, ^19.3). 주소 판정 규칙(javascript: 같은 스킴 거부, 호스트 정규화, rel 토큰)은 @plott/webkit-sanitize/url 이 한 곳에서 갖고, 이 패키지는 그것을 가져다 씁니다.
붙이기
서버 컴포넌트에서는 Markdown 을 바로 그립니다.
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 }} />;목차·요약까지 필요하면 한 번 파싱해서 나눠 씁니다.
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 지움입니다.
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 은 글자로. 개별 옵션이 먼저입니다 |
gfm | true | 표·취소선·할 일 목록·자동 링크·각주. 취소선은 ~~글~~ 만(10~20 은 그대로) |
html | "escape" | 원문 HTML 을 글자로 보여 줍니다. "drop" 은 지웁니다. 통과시키는 값은 없습니다 |
maxLength / truncate | 200_000 / false | 글자 수 한도. 넘으면 파싱 전에 던지고, truncate: true 면 앞부분만 씁니다 |
maxDepth | 32 | 인용·목록·강조의 중첩 한도 |
maxNodes | 50_000 | 노드 수 한도(거대한 표) |
links.protocols | http 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 / rewrite | false / "lazy" / 없음 | http 이미지 허용 / 로딩 / 이미지 프록시용 주소 바꾸기 |
images | 객체 | false 면 이미지를 모두 대체 글(alt)로 바꿉니다 |
headings.ids | true | 제목 id. 함수 (글자, 순번) => id 도 됩니다. false 면 id 와 목차 링크 없음 |
maxInlineMarks | 2000 | 한 문단(코드·표 줄 제외)의 인라인 표지(*·_·~·백틱·[·]·<) 한도. 문서 전체로는 이 한도 문단 8개 분량까지. 넘으면 파싱 전에 limit: "inline" |
maxParseMs | 2000 | 파싱 단계(파싱·변환)마다 재는 시간 한도(ms). 넘으면 뒷단으로 가지 않고 limit: "time" |
headings.idPrefix | "user-content-" | 제목 id 와 각주 id(fn-1·fnref-1·footnote-label) 앞에 붙일 글자. 문서 안 조각 링크([설치](#설치))의 href 에도 붙어 제목 id 와 맞습니다(이미 접두사로 시작하는 조각은 그대로). "" 는 직접 고르는 선택이며 그때는 조각 링크를 바꾸지 않고, 전역 이름 거부 목록이 돕니다 |
headings.reservedIds | RESERVED_HEADING_IDS(location·window·constructor … 전역 이름) | 제목 id 가 되면 -1 을 붙여 비켜 가는 이름. 주면 기본 목록을 대신하고, 접두사가 있어도 적용합니다. 기본 목록은 RESERVED_HEADING_IDS 로 내보냅니다 |
headings.shift / maxTocDepth | 0 / 6 | 제목 단계 낮추기(h1→h2) / 목차 깊이(화면에 나가는 단계 기준) |
footnotes | 영어 | { label, backLabel } 각주 영역 글. 화면 언어에 맞게 주세요 |
parseMarkdown 은 { hast, toc, text, words } 를, markdownToText 는 평문(엔티티가 풀리고 마크업이 없음, 원문 HTML 은 지움)을, excerpt(src, 길이 | { length, ellipsis, markdown }) 는 요약을 돌려줍니다.
기본으로 지키는 것
각 항목은 test/ 의 양성:·음성: 시험으로 확인합니다. 시간 한도는 시계를 고정해(performance.now 대체) 시험하고, 나머지 한도는 파서·변환 단계가 시작됐는지 세어 시험합니다.
javascript:·vbscript:·data:링크·이미지가 나가지 않습니다.javascript:, 탭·개행(jav	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 에도 접두사가 붙어 있다는 점을 계산에 넣어야 합니다.