From 082c6a961935684be6749049b03077b3c9f6966b Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 8 May 2026 09:47:49 +0900 Subject: [PATCH] =?UTF-8?q?v0.0.48=20Thred=ED=98=95=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=C2=B7=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EC=99=80=20X=20=EC=9E=84=EB=B2=A0=EB=93=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 북마크·뉴스레터 CTA 마크다운 블록과 컴포넌트를 추가하고, Twitter/X URL은 공식 embed iframe으로 렌더링한다. Callout 강조선과 이미지 캡션 색을 테마 변수에 맞춘다. Co-authored-by: Cursor --- .../content/ContentMarkdownRenderer.vue | 127 +++++++++++++++++- components/content/ProseBookmark.vue | 95 +++++++++++++ components/content/ProseCallout.vue | 2 +- components/content/ProseEmbed.vue | 57 +++++++- components/content/ProseImage.vue | 2 +- components/content/ProseSignup.vue | 44 ++++++ docs/history.md | 8 ++ docs/map.md | 4 +- docs/spec.md | 20 ++- docs/todo.md | 2 - docs/update.md | 6 + package.json | 2 +- 12 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 components/content/ProseBookmark.vue create mode 100644 components/content/ProseSignup.vue 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 }} + +