블로그의 핵심 기능인 Markdown 편집기의 구현 내용을 정리한 게시글.
12일차부터 Milkdown/crepe를 적용하여 진행하였다.
Remark
Remark는 마크다운을 AST로 변환하는 대표적인 라이브러리이다.
AST
**AST(Abstract Syntax Tree)**는
프로그래밍 언어나 마크다운처럼 문법이 있는 텍스트를
계층적인 트리 구조로 해석한 표현이다.
# Hello **world**
위의 마크다운을 Remark는 아래와 같이 파싱한다.
{
"type": "root",
"children": [
{
"type": "heading",
"depth": 1,
"children": [
{ "type": "text", "value": "Hello " },
{
"type": "strong",
"children": [
{ "type": "text", "value": "world" }
]
}
]
}
]
}
Remark 파싱 단계
remark 기반 마크다운 파싱의 전체 흐름
입력 (Markdown 문자열)
↓
1단계: remark-parse (Markdown → MDAST)
↓
2단계: remark 플러그인 (AST 가공, 수정, 변환)
↓
3단계: remark-rehype (MDAST → HAST)
↓
4단계: rehype 플러그인 (HTML AST 가공)
↓
5단계: rehype-stringify or rehype-react (HTML 문자열 or React 엘리먼트)
↓
출력 (HTML or React JSX)
remark-parse
: 마크다운 문자열 → MDAST
-
입력:
# Hello
-
출력: Markdown AST (MDAST)
{ "type": "root", "children": [ { "type": "heading", "depth": 1, "children": [{ "type": "text", "value": "Hello" }] } ] }
-
원리: 마크다운 문법을 정해진 문법에 따라 토큰화 → 트리 구조로 정리
remark 플러그인 단계 (선택적)
- 역할: AST를 직접 가공하는 미들웨어
- 예:
remark-gfm
,remark-footnotes
,remark-breaks
- AST를 순회하면서 노드를 추가하거나 수정 가능
unified().use(remarkParse).use(remarkGfm) // GitHub Flavored Markdown 확장
remark-rehype
: MDAST → HAST 변환
-
Markdown AST(MDAST) → HTML AST(HAST)로 변환
-
예:
unified().use(remarkParse).use(remarkRehype)
-
이 단계에서 "마크다운 의미"를 HTML 표현으로 매핑 (예:
heading
→<h1>
)
rehype 플러그인 단계
- 역할: HTML AST를 추가적으로 가공
- 예:
rehype-highlight
,rehype-autolink-headings
렌더링 단계 (출력 포맷 선택)
-
HAST → HTML 문자열
.use(rehypeStringify)
-
HAST → React 컴포넌트
.use(rehypeReact, { createElement: React.createElement })
요약
구성요소 | 역할 |
---|---|
remark-parse | 마크다운 문자열 → Markdown AST (MDAST) |
remark-xxx | MDAST를 수정하거나 기능 확장 |
remark-rehype | MDAST → HTML AST (HAST)로 변환 |
rehype-xxx | HAST 기반 추가 가공 (하이라이트, 링크 자동 추가 등) |
rehype-react | HAST → React 엘리먼트로 렌더링 |
ProseMirror
ProseMirror는 리치 텍스트 에디터를 만들기 위한 모듈형 프레임워크이다.
구조화된 문서를 기반으로 한다.
아래는 유저가 마크다운을 입력할 때에 HTML로 변환하며 화면에 렌더링하는 다이어그램 예제이다.
[User Input]
└─ "Hello **World**" 입력
↓
[Input Parser Layer] ← ex: markdown-it, custom tokenizer
└─ 마크다운 문법을 분석
↓
[ProseMirror Node Tree 생성]
└─ 구조화된 트리 형태로 파싱
{
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Hello " },
{ type: "text", marks: [{ type: "strong" }], text: "World" }
]
}
]
}
↓
[EditorState & EditorView]
└─ ProseMirror 에디터가 트리를 기반으로 상태를 구성
↓
[DOMSerializer / ProseMirror Renderer]
└─ HTML DOM fragment로 렌더링
↓
[Rendered Output in Browser]
└─ 브라우저 화면
<p>Hello <strong>World</strong></p>
Milkdown
Milkdown은 ProseMirror 기반 마크다운 에디터 라이브러리이다.
- 플러그인 기반으로 하여 번들 용량을 최소화할 수 있다.
- Milkdown/crepe라는 다양한 플러그인들이 포함된 라이브러리도 제공한다.
이미지 업로드 등 다양한 플러그인들이 이미 상당히 개발이 완료되었고,
Milkdown/crepe는 React와의 호환성까지 고려하여 플러그인이 적용되어 있기에 Milkdown/crepe를 블로그에 도입하게 되었다.
ProseMirror 기반의 에디터들을 다룰 때 유의할 점은, DOM이 있어야 렌더링을 할 수 있다. 따라서 Next.js에서는 서버 렌더링을 시도하지 않고 클라이언트에서만 렌더링하도록 ssr: false
옵션과 함께 Dynamic Import를 하여야 한다.
/main/components/markdown/markdown-editor.tsx
import dynamic from "next/dynamic";
const MilkdownWrapper = dynamic(() => import("@/components/markdown/milkdown-app/milkdown-wrapper"), {
ssr: false,
});
export default function MarkdownEditor({ markdown }: { markdown: string }) {
return <MilkdownWrapper markdown={markdown} />
}
마크다운 편집기 with SSR 프리뷰
/main/components/markdown/markdown-editor.tsx
Milkdown이 클라이언트 측에서 바로 마운트되도록 하려 하였으나, 편집을 자주 하지 않음에도 불구하고 Milkdown을 마운트하는 것이 오버헤드가 크다고 생각하여 명시적으로 클라이언트 측에서 상호작용을 할 때에만 import/mounted 되도록 하였다.
-
SSR이 가능한 Remark 기반 마크다운 파서를 이용해 렌더링한다.
-
클라이언트에서 편집모드를 켜면 ProseMirror 기반의 마크다운 편집기를 import하며 동작한다.
- 이때
position: absloute;
로 ProseMirror편집기가 Remark파서와 겹쳐서 노출되도록 하여 Layout Shift를 방지한다.
- 이때
-
ProseMirror 기반 마크다운 편집기와 State를 공유하는 CodeMirror 기반의 Raw 편집기도 추가.
프리뷰 (react-markdown)
/main/components/markdown/milkdown-app/milkdown-preview.tsx
export default function ReactMarkdownApp({ children }: { children?: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkParse, remarkBreaks]}
rehypePlugins={[rehypeHighlight]}
components={{
img: ({ src, alt = "" }) => {
if (!src)
return (
<Image
src={
"data:image/svg+xml;base64," +
"..."
}
alt="image-error"
width="0"
height="0"
sizes="(max-width: 768px) 100vw, 50vw"
className="w-auto h-[180px] shadow-glass"
/>
);
return (
<Image
src={src}
alt={alt}
width="0"
height="0"
sizes="(max-width: 768px) 100vw, 50vw"
className="w-auto h-[180px] sm:h-[220px] md:h-[260px] lg:h-[300px] xl:h-[340px]"
/>
);
},
}}
>
{children}
</ReactMarkdown>
);
}
rehypeHighlight
플러그인으로 코드 블럭에 하이라이트를 준다.components
옵션에서 img 태그를 next/image 태그로 변경하여 성능최적화를 하였다.
Wysiwyg 편집기 (milkdown/crepe)
/main/components/markdown/milkdown-app/milkdown-app.tsx
const MilkdownEditor = ({
markdown,
setMarkdown,
onImageUpload,
isFocused,
}: {
markdown: string;
setMarkdown: (markdown: string) => void;
onImageUpload?: (file: File) => Promise<string>;
isFocused: boolean;
}) => {
const uploadImageToServer = async (_file: File) => {
return "";
};
const crepeRef = useRef<Crepe | null>(null);
const [body, setBody] = useState<string>(markdown);
useEffect(() => {
if (isFocused) {
setMarkdown(body);
}
}, [body, isFocused, setMarkdown]);
useEditor((root) => {
const crepe = new Crepe({
root,
defaultValue: markdown,
...
});
crepe.on((listener) => {
listener.markdownUpdated((_, updatedMarkdown, prevMarkdown) => {
if (updatedMarkdown !== prevMarkdown) {
const cleaned = updatedMarkdown.replace(/<br\s*\/?>/gi, " ");
setBody(cleaned);
}
});
});
crepeRef.current = crepe;
return crepe;
}, []);
useEffect(() => {
if (!isFocused && crepeRef.current) {
try {
crepeRef.current.editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
...
const state = view.state;
...
let tr = state.tr;
...
view.dispatch(tr);
});
} catch (e) {
console.error(e);
}
}
}, [isFocused, markdown]);
return <Milkdown />;
};
export default MilkdownEditor;
useEditor
를 이용해 업로드에 필요한 플러그인을 추가한다. 밀크다운은</br>
태그를 통해 trailling 공백을 처리하는데, 이를 
로 파싱하도록 수정하여 remark와 호환되도록 했다. (정상적인</br>
태그도 파싱되는 단점.)useEffect
를 이용하여 외부에서 markdown이 변경되었을 때 이를 반영하도록 수정한다. Milkdown 홈페이지의 playground에서 코드를 따왔다. github - Milkdown/website/Playground
RAW 편집기 (CodeMirror)
/main/components/markdown/milkdown-app/markdown-raw-editor.tsx
github - Milkdown/website/Playground의 코드를 참고하여 작성하였고, 다크 모드를 감지하는 로직만 추가하였다.
편집기 렌더러
/main/components/markdown/milkdown-app/milkdown-wrapper.tsx
편집모드가 켜지면 이에 맞는 편집기를 보여준다.
Wysiwyg과 Raw가 둘 다 켜지면, grid를 적용하여 좌 우에