v0.0.48 Thred형 북마크·회원가입 카드와 X 임베드 보강

북마크·뉴스레터 CTA 마크다운 블록과 컴포넌트를 추가하고, Twitter/X URL은 공식 embed iframe으로 렌더링한다.
Callout 강조선과 이미지 캡션 색을 테마 변수에 맞춘다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-08 09:47:49 +09:00
parent 5f2b2b8c4f
commit 082c6a9619
12 changed files with 354 additions and 15 deletions

View File

@@ -29,7 +29,8 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
variant: options.variant || '',
ordered: options.ordered || false,
width: options.width || 'regular',
images: options.images || []
images: options.images || [],
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {}
})
/**
@@ -72,6 +73,96 @@ const collectFencedLines = (lines, startIndex) => {
}
}
/**
* 북마크 fenced 블록 본문에서 URL·제목·설명·썸네일을 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{url: string, title: string, description: string, thumbnail: string}} 북마크 메타
*/
const parseBookmarkMeta = (raw) => {
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
const meta = {
url: '',
title: '',
description: '',
thumbnail: ''
}
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (kv) {
const key = kv[1].toLowerCase()
const val = kv[2].trim()
if (key === 'url') {
meta.url = val
} else if (key === 'title') {
meta.title = val
} else if (key === 'description' || key === 'desc') {
meta.description = val
} else if (key === 'thumbnail' || key === 'image') {
meta.thumbnail = val
}
continue
}
if (!meta.url && /^https?:\/\//i.test(line)) {
meta.url = line
continue
}
if (meta.url && !meta.title) {
meta.title = line
continue
}
if (meta.url && meta.title && !meta.description) {
meta.description = line
}
}
return meta
}
/**
* 회원가입(CTA) fenced 블록 본문에서 표시 문구를 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{title: string, description: string, button: string, placeholder: string}} CTA 메타
*/
const parseSignupMeta = (raw) => {
const meta = {
title: '뉴스레터에 가입하세요',
description: '새 글이 올라오면 받아보실 수 있어요.',
button: '구독하기',
placeholder: 'you@example.com'
}
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (!kv) {
continue
}
const key = kv[1].toLowerCase()
const val = kv[2].trim()
if (key === 'title') {
meta.title = val
} else if (key === 'description' || key === 'desc') {
meta.description = val
} else if (key === 'button') {
meta.button = val
} else if (key === 'placeholder') {
meta.placeholder = val
}
}
return meta
}
/**
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
* @param {string} markdown - 마크다운 문자열
@@ -105,6 +196,26 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (trimmedLine === ':::bookmark') {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
if (bookmarkMeta.url) {
blocks.push(createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }))
}
index = nextIndex
continue
}
if (trimmedLine === ':::signup') {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const signupMeta = parseSignupMeta(contentLines.join('\n'))
blocks.push(createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }))
index = nextIndex
continue
}
if (trimmedLine === ':::gallery') {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const images = []
@@ -287,6 +398,20 @@ const showNextImage = () => {
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
{{ block.text }}
</ProseToggle>
<ProseBookmark
v-else-if="block.type === 'bookmark' && block.meta.url"
:url="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
:thumbnail="block.meta.thumbnail"
/>
<ProseSignup
v-else-if="block.type === 'signup'"
:title="block.meta.title"
:description="block.meta.description"
:button-label="block.meta.button"
:placeholder="block.meta.placeholder"
/>
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
<div v-else-if="block.type === 'gallery'" class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3">
<button