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

View File

@@ -0,0 +1,95 @@
<script setup>
const props = defineProps({
url: {
type: String,
required: true
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
thumbnail: {
type: String,
default: ''
}
})
/**
* 북마크 카드에 표시할 호스트명을 반환한다.
* @returns {string} www 없는 호스트 또는 빈 문자열
*/
const displayHost = computed(() => {
try {
return new URL(props.url).hostname.replace(/^www\./, '')
} catch {
return ''
}
})
/**
* 썸네일이 비었을 때 파비콘 보조 URL을 만든다.
* @returns {string} favicon 요청 URL
*/
const faviconUrl = computed(() => {
if (!displayHost.value) {
return ''
}
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(displayHost.value)}&sz=128`
})
/**
* 실제로 표시할 이미지 주소
* @returns {string}
*/
const imageSrc = computed(() => props.thumbnail || faviconUrl.value)
/**
* 표시 제목(없으면 호스트·URL)
* @returns {string}
*/
const displayTitle = computed(() => props.title || displayHost.value || props.url)
</script>
<template>
<a
class="prose-bookmark group prose-bookmark-card my-8 flex max-w-full flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] no-underline transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:flex-row"
:href="url"
target="_blank"
rel="noopener noreferrer"
>
<div class="prose-bookmark__media relative h-36 w-full shrink-0 overflow-hidden bg-[color-mix(in_srgb,var(--site-line)_40%,var(--site-panel))] sm:h-auto sm:w-[min(44%,220px)] sm:min-h-[9rem]">
<img
v-if="imageSrc"
class="prose-bookmark__thumb h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
:src="imageSrc"
alt=""
loading="lazy"
>
</div>
<div class="prose-bookmark__body flex min-w-0 flex-1 flex-col justify-center gap-1 px-4 py-4 sm:px-5 sm:py-5">
<p v-if="displayHost" class="prose-bookmark__host text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--site-muted)]">
{{ displayHost }}
</p>
<p class="prose-bookmark__title text-[15px] font-semibold leading-snug text-[var(--site-text)]">
{{ displayTitle }}
</p>
<p v-if="description" class="prose-bookmark__desc line-clamp-2 text-sm leading-relaxed text-[var(--site-muted)]">
{{ description }}
</p>
<p class="prose-bookmark__meta mt-1 flex items-center gap-1.5 text-xs font-medium text-[var(--site-soft)]">
<svg class="shrink-0 opacity-80" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6" />
<path d="M11 13l9 -9" />
<path d="M15 4h5v5" />
</svg>
<span class="truncate">{{ url }}</span>
</p>
</div>
</a>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<aside class="prose-callout my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5 text-[15px] leading-8 text-[var(--site-text)]">
<aside class="prose-callout prose-callout-card my-8 rounded-[10px] border border-[var(--site-line)] border-l-[3px] border-l-[var(--site-accent)] bg-[var(--site-panel)] p-5 pl-4 text-[15px] leading-8 text-[var(--site-text)]">
<div class="whitespace-pre-line">
<slot />
</div>

View File

@@ -6,8 +6,10 @@ const props = defineProps({
}
})
const { theme } = useThemeMode()
/**
* YouTube 영상 ID를 추출
* YouTube 영상 ID를 추출한다.
* @param {string} value - 임베드 URL
* @returns {string} YouTube 영상 ID
*/
@@ -29,12 +31,55 @@ const getYouTubeId = (value) => {
return ''
}
/**
* Twitter/X 게시물 ID를 추출한다.
* @param {string} value - 트윗 URL
* @returns {string} 상태 ID
*/
const getTweetId = (value) => {
try {
const trimmed = value.trim()
const parsedUrl = new URL(trimmed)
const host = parsedUrl.hostname.replace(/^www\./, '')
if (!['twitter.com', 'x.com', 'mobile.twitter.com'].includes(host)) {
return ''
}
const parts = parsedUrl.pathname.split('/').filter(Boolean)
const statusIdx = parts.indexOf('status')
if (statusIdx >= 0 && parts[statusIdx + 1]) {
return parts[statusIdx + 1].split(/[?#]/)[0] || ''
}
} catch {
return ''
}
return ''
}
const youtubeId = computed(() => getYouTubeId(props.url))
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
const tweetId = computed(() => getTweetId(props.url))
/**
* Twitter 공식 embed iframe 주소
* @returns {string}
*/
const tweetEmbedUrl = computed(() => {
if (!tweetId.value) {
return ''
}
const twitterTheme = theme.value === 'dark' ? 'dark' : 'light'
return `https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(tweetId.value)}&theme=${twitterTheme}&dnt=true`
})
</script>
<template>
<div class="prose-embed my-8 overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<div class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<iframe
v-if="youtubeEmbedUrl"
class="prose-embed__frame aspect-video w-full"
@@ -44,6 +89,14 @@ const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.co
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
/>
<iframe
v-else-if="tweetEmbedUrl"
:key="tweetEmbedUrl"
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
:src="tweetEmbedUrl"
title="Embedded post"
loading="lazy"
/>
<a
v-else
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"

View File

@@ -26,7 +26,7 @@ defineProps({
<div class="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
</div>
<figcaption v-if="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-muted">
<figcaption v-if="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-[var(--site-muted)]">
<slot />
</figcaption>
</figure>

View File

@@ -0,0 +1,44 @@
<script setup>
defineProps({
title: {
type: String,
default: '뉴스레터에 가입하세요'
},
description: {
type: String,
default: '새 글이 올라오면 받아보실 수 있어요.'
},
buttonLabel: {
type: String,
default: '구독하기'
},
placeholder: {
type: String,
default: 'you@example.com'
}
})
</script>
<template>
<section class="prose-signup prose-signup-card my-8 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel-strong)] px-5 py-8 text-center sm:px-8">
<h3 class="prose-signup__title text-[clamp(1rem,0.95rem+0.25vw,1.125rem)] font-semibold leading-snug text-[var(--site-text)]">
{{ title }}
</h3>
<p class="prose-signup__desc mt-2 text-[15px] leading-7 text-[var(--site-muted)]">
{{ description }}
</p>
<form class="prose-signup__form mt-6 flex flex-col items-stretch gap-2.5 sm:flex-row sm:justify-center" action="#" @submit.prevent>
<input
class="site-input prose-signup__input min-h-[44px] w-full rounded-full px-4 text-sm sm:max-w-[300px] sm:flex-1"
type="email"
:placeholder="placeholder"
readonly
tabindex="-1"
aria-label="이메일"
>
<button class="site-accent-button prose-signup__submit min-h-[44px] shrink-0 rounded-full px-8 text-sm font-semibold" type="button">
{{ buttonLabel }}
</button>
</form>
</section>
</template>

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-05-08 v0.0.48
### Twitter/X 공식 embed iframe과 북마크·회원가입 마크다운 블록
공개 본문에서 트위터 게시물은 `platform.twitter.com/embed/Tweet.html` iframe으로 표시한다. oEmbed API나 스크립트 삽입에 비해 구현이 단순하고 SSR·테마(`useThemeMode`)와 `theme` 쿼리만 맞추면 라이트/다크 일관성을 유지하기 쉽기 때문이다.
북마크·뉴스레터 CTA는 Ghost/Thred 스킨에서 흔한 카드 패턴이므로 `:::bookmark`·`:::signup` 확장 블록으로 저장하고 전용 Vue 컴포넌트로 렌더링한다. 메타데이터 풍부한 북마크는 추후 oEmbed나 서버 페치로 보강할 수 있도록 마크다운 키값 형식을 병행한다.
## 2026-05-07 v0.0.45
### 사용자 화면 단일 배경과 사이드바 전환 방식 결정

View File

@@ -48,8 +48,10 @@
| components/content/ProseAudio.vue | 오디오 |
| components/content/ProseFile.vue | 파일 |
| components/content/ProseProduct.vue | 상품 카드 |
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·외부 링크) |
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
| components/content/ProseHeaderCard.vue | 헤더 카드 |
| components/content/ProseEmbed.vue | YouTube, Twitter 임베드 |
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 외부 링크 |
## 관리자 페이지

View File

@@ -99,8 +99,10 @@ components/content/
├── ProseAudio.vue # Audio 카드
├── ProseFile.vue # File 카드
├── ProseProduct.vue # Product 카드
├── ProseBookmark.vue # 북마크 카드(썸네일·제목·도메인)
├── ProseSignup.vue # 회원가입/뉴스레터 CTA 카드
├── ProseHeaderCard.vue # Header 카드 (Simple/Wide/Full-width/Split)
└── ProseEmbed.vue # Embeds (YouTube, Twitter)
└── ProseEmbed.vue # Embeds (YouTube iframe, Twitter/X iframe, 기타 링크)
```
### 공개 본문 스타일 가이드(Thred 기준)
@@ -121,10 +123,12 @@ components/content/
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
- 카드류
- Callout: `:::callout` ~ `:::`
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
- Toggle: `:::toggle 제목` ~ `:::`
- Embed: `:::embed` ~ `:::` (YouTube URL은 iframe, 그 외는 링크)
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseEmbed.vue`
- Bookmark: `:::bookmark` ~ `:::` (본문은 `url=`, `title=`, `description=`, `thumbnail=` 키값 또는 첫 줄 URL·이어지는 제목/설명 줄)
- Signup: `:::signup` ~ `:::` (선택: `title=`, `description=`, `button=`, `placeholder=`)
- Embed: `:::embed` ~ `:::` (YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드)
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseBookmark.vue`, `ProseSignup.vue`, `ProseEmbed.vue`
---
@@ -365,7 +369,11 @@ components/content/
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다.
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다.
- 그 외 URL은 외부 링크 텍스트 카드로 표시한다.
- 북마크 블록은 `:::bookmark` fenced block으로 저장할 수 있으며 공개 화면에서 Thred형 가로 카드로 렌더링한다.
- 회원가입(뉴스레터) CTA는 `:::signup` fenced block으로 저장할 수 있으며 실제 폼 연동은 후속 작업으로 분리한다.
### 관리자 페이지 편집
@@ -481,6 +489,6 @@ APP_PORT=43118
## 버전 관리
- 현재 버전: v0.0.47
- 현재 버전: v0.0.48
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -30,8 +30,6 @@
- [ ] ProseFile 실제 파일 데이터 연결
- [ ] ProseProduct 실제 상품 카드 데이터 연결
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
- [ ] ProseEmbed Twitter 실제 렌더링 연결
## 데이터베이스
- [ ] NAS 운영 DB 연결 설정 실제 값 작성

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v0.0.48
- 공개 본문에 Thred형 북마크 카드(`ProseBookmark`, `:::bookmark`)와 회원가입 CTA(`ProseSignup`, `:::signup`) 추가.
- `ProseEmbed`에서 Twitter/X 게시물 URL을 공식 embed iframe으로 렌더링하고 테마와 동기화.
- Callout 왼쪽 강조선·이미지 캡션 변수 색상 등 카드/미디어 스타일을 Thred에 가깝게 보정.
## v0.0.47
- 공개 본문 렌더러에서 Ordered list(숫자 목록) 문법을 지원.

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.47",
"version": "0.0.48",
"private": true,
"type": "module",
"scripts": {