블로그 제작 #2 - 에디터(Link 카드)
2026년 9월 11일

에디터 내 Link 붙여넣기 기능
음.. 블로그 글 작성을 자알~ 사용해 오다가 갑자기 불현듯 이게 불편해! 하면 그걸 고쳐나가면서 사용하고 있다
이번에 만든 블로그는 생각보다 글 작성하기가 편해서 그런지 부담없이 포스트를 이것저것 올릴 수 있게 된 것 같은데,,, 뭐시기냐
그 링크를 붙여넣다보니 그냥 https://xxx.xxx 이렇게 들어가 버리길래 뭔가 네이버 블로그 같이 붙여넣으면 카드형식으로 들어가면 좋겠다 싶어 기능을 개발하게 되었다
대충 그려보는 로직
사용자가 붙여넣기를 하면 →
[브라우저] paste 이벤트 발생 + clipboardData →
[textarea onPaste] body-pane →
[use-images.onPaste] 분기
- files 존재 → 이미지 업로드 / preventDefault / return
- files 없음 → pasteLinkCard
- plain text 읽기
- isHttpUrl? or → return false (브라우저 기본 붙여넣기)
- isStandaloneUrlLine? or → return false (기본 붙여넣기)
- preventDefault
- YouTube? → insertAtCursor(url) → 끝
- fetchLinkPreviewAction (server)
- HTML fetch + og 파싱
- return data
- false → insertAtCursor(url)
- true → serializeLinkCard → insertAtCursor(fence)
→ setBody → useWritePreview
→ marked code(link-card)
→ figure.markdown-link-card (미리보기)
요약해보자면
Ctrl + v
- 브라우저가 textarea에 paste 이벤트 발생
- body-pane의 onPaste
- use-images.onPaste
- (이미지 아님) pasteLinkCard
- clipboard에서 텍스트 읽기
- URL + 빈 줄이면 preventDefault
- Server Action 으로 OG fetch
link-card JSON을 insertAtCursor- body state 변경 → 미리보기 renderMarkdown → 카드 HTML
처리 과정
Ctrl + v 동작
| 단계 | 파일 | 역할 |
|---|---|---|
| UI | body-pane.tsx | <textarea onPaste={onPaste}> |
| 전달 | write-form.tsx | useWriteForm이 준 onPaste를 pane에 넘김 |
| 출처 | use-write-form.ts | useWriteImages(..., insertAtCursor, setStatus)의 onPaste |
| 실제 처리 | use-images.ts | onPaste / pasteLinkCard |
브라우저의 기본 동작
- 사용자가 textarea에 포커스 한 채 Ctrl + v (또는 Cmd + v, 우클릭 붙여넣기)
- 브라우저가
paste이벤트를 그 textarea에 디스패치 - React가
onPaste={...}로 연결한 핸들러를 호출
클립보드 내용은 어디서 오나
use-images.ts의 onPaste / pasteLinkCard 안
e.clipboardData.files
e.clipboardData.getData("text/plain")clipboardData는 브라우저가 paste 이벤트에 실어주는 DataTransfer- OS의 클립보드 API를 직접 여는게 아닌 붙여넣기용 스냅샷
"text/plain": URL, 일반 텍스트.files: 스샷, 파일 복사시. 이미지가 있으면 URL 카드 로직 X, 이미지 업로드 분기
function onPaste(e) {
const files = [...e.clipboardData.files].filter(...);
if (files.length) {
e.preventDefault();
// uploadImageAction
return;
}
void pasteLinkCard(e);
}pasteLinkCard 카드 분기점
- 텍스트가 URL인지
const text = e.clipboardData.getData("text/plain").trim();
if (!text || !isHttpUrl(text)) return false;isHttpUrl (link-card.ts)
https://...,http://...형식new URL()이 성공해야 truehello,www.naver.com(스킴 없음)등은 카드로 선택 안 함 →return false→preventDefault` 안 함 → 브라우저가 그냥 텍스트로 붙여넣음
- 한 줄짜리 URL 붙여넣기인지
const el = e.currentTarget;
if (!isStandaloneUrlLine(el.value, el.selectionStart, el.selectionEnd)) {
return false
}isStandaloneUrlLine
- 커서가 속한 줄의 시작/끝을
\n으로 찾음 - 그 줄
trim()의 결과가 빈 줄이거나 이미 URL만 있다면 true - 드래그 선택이 있으면, 선택 내용이 줄 전체, URL이 아니면 false
따라서 정리하자면
| 상황 | 결과 |
|---|---|
| 빈 줄에서 URL 붙여넣기 | 카드 시도 |
hello 뒤에 URL 붙여넣기 |
일반 텍스트 |
| 줄 전체가 URL인 상태에서 다시 붙여넣기 | 카드 시도 |
에서 true이면 e.preventDefault(); 해서 브라우저 기본 붙여넣기를 막고 본문 조작
유튜브
if (extractYoutubeId(text)) {
insertAtCursor(text);
return true;
}본문에 URL만 들어가면 기존 markdown.ts의 link/image 렌더러가 iframe으로 바뀌기 때문에 OG 카드와 중복되지 않게 분기
서버에서 OG 가져오기
setStatus("링크 미리보기 만드는 중...");
const result = await fetchLinkPreviewAction(text);Server Action인 이유
- 클라이언트가
fetch하면 CORS 때문에 HTML을 못 읽는 경우 많음 - 서버(
use server)에서 fetch하면 그 제한이 없음 - localhost/사설IP는
isPrivateHostname으로 막아 SSRF 완화
- 클라이언트가
Server가 하는 일(link-preview.ts)
auth(): 관리자만- URL 재검증, private host 차단
- `fetch(url, { signal: 8s abort, redirect: follow, User-Agent: ... })
- HTML이 아니면 에러
- 본문 앞 500KB 보고 정규식으로
og:title→ 없으면<title>→ 없으면 hostnameog:description/descriptionog:image/twitter:image(상대경로면absoluteUrl로 절대경로)
{ data: LinkCardData }반환
실패 시 클라이언트
setStatus(result.error ?? "미리보기 실패");
insertAtCursor(text);본문에 '스냅샷'으로 넣는 방법
성공 시
insertAtCursor(serializeLinkCard(result.data));URL만 저장하지 않고 title/image까지 JSON으로 넣는 이유
나중에 대상 사이트 OG가 변경되거나 막혀도 글에 남은 카드는 그 때 스냅샷 그대로로 사용하기 위해
insertAtCursor (use-body.ts)
- textarea의
selectionStart/selectionEnd insertSnippet(keys.ts) 앞부분 + "\n\n" + snippet + "\n" + 뒷부분setBody(next)→ React statebody갱신- 커서를 snippet 뒤로 이동
Form의 name="body"인 textarea는 value={body}라서, state가 바뀌면 화면에 fence가 보임
미리보기, 발행된 포스트에서 카드로 보이는 과정
body가 바뀌면 useWritePreview(body):
renderMarkdown(body);markdown.ts의 code 렌더러:
if (isLinkCardLang(lang)) {
return renderLinkCardHtml(text);
}renderLinkCardHtml이 제목, 설명, 이미지, 호스트 HTML을 만들고, DOMPurify가 허용 태그(figure 등)만 남김
포스트 발행 후 DB body에 같은 fence가 저장되고, 포스트 상세보기도 renderMarkdown / markdown-server 파이프라인을 쓰므로 미리보기 = 상세보기
body에 ```가 있으면 미리보기는 250ms 뒤 Shiki 경로(markdown-highlight)도 타는데, link-card는 그 안에서도 같은 createMarkdownParser의 code` 분기라 카드로 렌더링됨
정리해보니...
정리해놓고 보니 무진장 복잡해 보이는데 실은 간단하다
이걸 글로 정리하니까 엄청 복잡해 보여.....(어지러워 @_@)
나중에 한 번 더 글 좀 다듬어야겠당..