D&K Dreams

개발

블로그 제작 #2 - 에디터(Link 카드)

2026년 9월 11일

블로그 제작 #2 - 에디터(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

브라우저의 기본 동작

  1. 사용자가 textarea에 포커스 한 채 Ctrl + v (또는 Cmd + v, 우클릭 붙여넣기)
  2. 브라우저가 paste 이벤트를 그 textarea에 디스패치
  3. 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 카드 분기점

  1. 텍스트가 URL인지
const text = e.clipboardData.getData("text/plain").trim();

if (!text || !isHttpUrl(text)) return false;

isHttpUrl (link-card.ts)

  • https://..., http://... 형식
  • new URL()이 성공해야 true
    • hello, www.naver.com(스킴 없음)등은 카드로 선택 안 함 → return false→preventDefault` 안 함 → 브라우저가 그냥 텍스트로 붙여넣음
  1. 한 줄짜리 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)

    1. auth(): 관리자만
    2. URL 재검증, private host 차단
    3. `fetch(url, { signal: 8s abort, redirect: follow, User-Agent: ... })
    4. HTML이 아니면 에러
    5. 본문 앞 500KB 보고 정규식으로
    • og:title → 없으면 <title> → 없으면 hostname
    • og:description / description
    • og:image / twitter:image (상대경로면 absoluteUrl로 절대경로)
    1. { data: LinkCardData } 반환

실패 시 클라이언트

setStatus(result.error ?? "미리보기 실패");
insertAtCursor(text);

본문에 '스냅샷'으로 넣는 방법

성공 시

insertAtCursor(serializeLinkCard(result.data));

URL만 저장하지 않고 title/image까지 JSON으로 넣는 이유
나중에 대상 사이트 OG가 변경되거나 막혀도 글에 남은 카드는 그 때 스냅샷 그대로로 사용하기 위해

insertAtCursor (use-body.ts)

  1. textarea의 selectionStart / selectionEnd
  2. insertSnippet(keys.ts) 앞부분 + "\n\n" + snippet + "\n" + 뒷부분
  3. setBody(next) → React state body 갱신
  4. 커서를 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` 분기라 카드로 렌더링됨

정리해보니...

정리해놓고 보니 무진장 복잡해 보이는데 실은 간단하다
이걸 글로 정리하니까 엄청 복잡해 보여.....(어지러워 @_@)

나중에 한 번 더 글 좀 다듬어야겠당..

댓글 0

아직 댓글이 없습니다.