diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue
index 7ac9ab7..756a6c1 100644
--- a/components/content/ContentMarkdownRenderer.vue
+++ b/components/content/ContentMarkdownRenderer.vue
@@ -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 = () => {
{{ block.text }}
+
+