Files
sori.studio/components/content/ContentMarkdownRenderer.vue

694 lines
23 KiB
Vue

<script setup>
const props = defineProps({
content: {
type: String,
default: ''
}
})
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
/**
* 마크다운 블록을 생성
* @param {string} type - 블록 타입
* @param {string|Array<string>} text - 블록 텍스트
* @param {number|null} level - 제목 레벨
* @param {string} id - 블록 ID
* @param {Object} options - 추가 블록 옵션
* @returns {Object} 블록
*/
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
id,
type,
text,
level,
url: options.url || '',
alt: options.alt || '',
title: options.title || '',
variant: options.variant || '',
ordered: options.ordered || false,
width: options.width || 'regular',
images: options.images || [],
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
calloutEmoji: options.calloutEmoji || '💡',
calloutBackground: options.calloutBackground || 'blue'
})
const calloutBackgroundOptions = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
/**
* 콜아웃 선언부 옵션을 파싱
* @param {string} line - 콜아웃 선언 라인
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
*/
const parseCalloutOptions = (line) => {
const options = {
calloutEmojiEnabled: true,
calloutEmoji: '💡',
calloutBackground: 'blue'
}
const tokens = line.trim().split(/\s+/).slice(1)
tokens.forEach((token) => {
const [rawKey, ...rawValueParts] = token.split('=')
if (!rawKey || !rawValueParts.length) {
return
}
const key = rawKey.toLowerCase()
const value = rawValueParts.join('=').trim()
if (key === 'emoji') {
if (!value || value === 'none') {
options.calloutEmojiEnabled = false
options.calloutEmoji = '💡'
} else {
options.calloutEmojiEnabled = true
options.calloutEmoji = value
}
return
}
if (key === 'bg' && calloutBackgroundOptions.includes(value)) {
options.calloutBackground = value
}
})
return options
}
/**
* 이미지 마크다운 행을 이미지 데이터로 변환
* @param {string} line - 마크다운 행
* @returns {Object|null} 이미지 데이터
*/
const parseImageLine = (line) => {
const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)
if (!match) {
return null
}
return {
alt: match[1],
url: match[2],
width: match[3] || 'regular'
}
}
/**
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} 블록 시작 여부
*/
const isMarkdownBlockStart = (line) => {
const trimmedLine = line.trim()
return trimmedLine === BLANK_PARAGRAPH_MARKER ||
trimmedLine === '>>>' ||
trimmedLine === ':::bookmark' ||
trimmedLine === ':::signup' ||
trimmedLine === ':::gallery' ||
trimmedLine === ':::embed' ||
trimmedLine.startsWith(':::callout') ||
trimmedLine.startsWith(':::toggle') ||
trimmedLine.startsWith('```') ||
trimmedLine === '---' ||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
trimmedLine.startsWith('> ') ||
/^- /.test(trimmedLine) ||
/^\d+\.\s+/.test(trimmedLine) ||
Boolean(parseImageLine(trimmedLine))
}
/**
* 마크다운 hard break 표식이 있는 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} hard break 여부
*/
const hasMarkdownHardBreak = (line) => /( {2,}|\\)$/.test(line)
/**
* 문단 행에서 hard break 표식을 제거한다.
* @param {string} line - 마크다운 행
* @returns {string} 정리된 문단 행
*/
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
/**
* 빈 줄 공백 블록 높이를 반환한다.
* @param {Object} block - 렌더링 블록
* @returns {string} Tailwind 높이 클래스
*/
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
/**
* 닫힘 표식까지의 행 목록을 반환
* @param {Array<string>} lines - 전체 마크다운 행
* @param {number} startIndex - 본문 시작 인덱스
* @returns {{contentLines: Array<string>, nextIndex: number}} 블록 본문과 다음 인덱스
*/
const collectFencedLines = (lines, startIndex) => {
const contentLines = []
let index = startIndex
while (index < lines.length && lines[index].trim() !== ':::') {
contentLines.push(lines[index])
index += 1
}
return {
contentLines,
nextIndex: index + 1
}
}
/**
* 북마크 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 - 마크다운 문자열
* @returns {Array<Object>} 블록 목록
*/
const parseMarkdownBlocks = (markdown) => {
const lines = markdown.split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index]
const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }))
index += 1
continue
}
if (!trimmedLine) {
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
index += 1
continue
}
if (trimmedLine === '>>>') {
const contentLines = []
index += 1
while (index < lines.length && lines[index].trim() !== '<<<') {
contentLines.push(lines[index])
index += 1
}
blocks.push(createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }))
index += 1
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 = []
contentLines.forEach((contentLine) => {
const image = parseImageLine(contentLine)
if (image) {
images.push(image)
}
})
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
index = nextIndex
continue
}
if (trimmedLine.startsWith(':::callout')) {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
index = nextIndex
continue
}
if (trimmedLine.startsWith(':::toggle')) {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
blocks.push(createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }))
index = nextIndex
continue
}
if (trimmedLine === ':::embed') {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
index = nextIndex
continue
}
const image = parseImageLine(trimmedLine)
if (image) {
blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image))
index += 1
continue
}
if (trimmedLine.startsWith('```')) {
const codeLines = []
index += 1
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index])
index += 1
}
blocks.push(createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`))
index += 1
continue
}
if (trimmedLine === '---') {
blocks.push(createBlock('divider', '', null, `block-${blocks.length}`))
index += 1
continue
}
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
index += 1
continue
}
if (trimmedLine.startsWith('> ')) {
const quoteLines = []
while (index < lines.length && lines[index].trim().startsWith('>')) {
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
index += 1
}
blocks.push(createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`))
continue
}
if (/^- /.test(trimmedLine)) {
const items = []
while (index < lines.length && /^- /.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^- /, ''))
index += 1
}
blocks.push(createBlock('list', items, null, `block-${blocks.length}`))
continue
}
if (/^\d+\.\s+/.test(trimmedLine)) {
const items = []
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^\d+\.\s+/, ''))
index += 1
}
blocks.push(createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }))
continue
}
const paragraphLines = [cleanParagraphLine(line)]
let shouldJoinNextLine = hasMarkdownHardBreak(line)
index += 1
while (shouldJoinNextLine && index < lines.length) {
const nextLine = lines[index]
const nextTrimmedLine = nextLine.trim()
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
break
}
paragraphLines.push(cleanParagraphLine(nextLine))
shouldJoinNextLine = hasMarkdownHardBreak(nextLine)
index += 1
}
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
}
return blocks
}
const blocks = computed(() => parseMarkdownBlocks(props.content))
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
/**
* 인라인 마크다운을 표시 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
* @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트
*/
const parseInlineSegments = (value) => {
const source = String(value || '')
const segments = []
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
let lastIndex = 0
let match = pattern.exec(source)
while (match) {
if (match.index > lastIndex) {
segments.push({
type: 'text',
text: source.slice(lastIndex, match.index)
})
}
if (match[2] && match[3]) {
segments.push({
type: 'link',
text: match[2],
href: match[3]
})
} else if (match[4]) {
segments.push({
type: 'strong',
text: match[4]
})
} else if (match[5]) {
segments.push({
type: 'code',
text: match[5]
})
} else if (match[6]) {
segments.push({
type: 'em',
text: match[6]
})
}
lastIndex = pattern.lastIndex
match = pattern.exec(source)
}
if (lastIndex < source.length) {
segments.push({
type: 'text',
text: source.slice(lastIndex)
})
}
return segments.length ? segments : [{ type: 'text', text: source }]
}
/**
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
* @returns {Array<Array<{ type: string, text: string, href?: string }>>} 줄별 인라인 세그먼트
*/
const parseInlineSegmentLines = (value) => {
return String(value || '').split('\n').map(parseInlineSegments)
}
/**
* 라이트박스를 연다
* @param {Array<Object>} images - 이미지 목록
* @param {number} index - 시작 인덱스
* @returns {void}
*/
const openLightbox = (images, index) => {
activeLightboxImages.value = images
activeLightboxIndex.value = index
}
/**
* 라이트박스를 닫는다
* @returns {void}
*/
const closeLightbox = () => {
activeLightboxImages.value = []
activeLightboxIndex.value = 0
}
/**
* 라이트박스 이전 이미지로 이동
* @returns {void}
*/
const showPreviousImage = () => {
activeLightboxIndex.value = activeLightboxIndex.value === 0
? activeLightboxImages.value.length - 1
: activeLightboxIndex.value - 1
}
/**
* 라이트박스 다음 이미지로 이동
* @returns {void}
*/
const showNextImage = () => {
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
}
</script>
<template>
<div class="content-markdown-renderer">
<template v-for="block in blocks" :key="block.id">
<div v-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</ProseHeading>
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</ProseBlockquote>
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</li>
</ProseList>
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
{{ block.alt }}
</ProseImage>
<ProseCallout
v-else-if="block.type === 'callout'"
:emoji-enabled="block.calloutEmojiEnabled"
:emoji="block.calloutEmoji"
:background="block.calloutBackground"
>
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-black/5 px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</ProseCallout>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</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
v-for="(image, imageIndex) in block.images"
:key="`${block.id}-${image.url}`"
class="content-markdown-renderer__gallery-button overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
type="button"
@click="openLightbox(block.images, imageIndex)"
>
<img class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]" :src="image.url" :alt="image.alt">
</button>
</div>
<pre
v-else-if="block.type === 'code'"
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
><code>{{ block.text }}</code></pre>
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
<p v-else class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
<br v-if="lineIndex > 0">
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</template>
</p>
</template>
<div
v-if="activeLightboxImage"
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
role="dialog"
aria-modal="true"
@click.self="closeLightbox"
>
<button class="content-markdown-renderer__lightbox-close absolute right-5 top-5 rounded bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeLightbox">
닫기
</button>
<button
v-if="activeLightboxImages.length > 1"
class="content-markdown-renderer__lightbox-prev absolute left-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
type="button"
@click="showPreviousImage"
>
이전
</button>
<img class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain" :src="activeLightboxImage.url" :alt="activeLightboxImage.alt">
<button
v-if="activeLightboxImages.length > 1"
class="content-markdown-renderer__lightbox-next absolute right-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
type="button"
@click="showNextImage"
>
다음
</button>
</div>
</div>
</template>