Files
sori.studio/components/admin/AdminMarkdownPreview.vue

153 lines
3.8 KiB
Vue

<script setup>
const props = defineProps({
content: {
type: String,
default: ''
}
})
/**
* HTML 특수 문자 이스케이프
* @param {string} value - 원본 문자열
* @returns {string} 이스케이프된 문자열
*/
const escapeHtml = (value) => value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
/**
* 인라인 마크다운 변환
* @param {string} value - 원본 문자열
* @returns {string} 변환된 HTML
*/
const parseInlineMarkdown = (value) => escapeHtml(value)
.replace(/`([^`]+)`/g, '<code class="admin-markdown-preview__inline-code">$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
/**
* 목록 HTML 변환
* @param {Array<string>} items - 목록 아이템
* @param {'ul' | 'ol'} tagName - 목록 태그명
* @returns {string} 목록 HTML
*/
const renderList = (items, tagName) => {
const listItems = items
.map((item) => `<li>${parseInlineMarkdown(item)}</li>`)
.join('')
return `<${tagName}>${listItems}</${tagName}>`
}
/**
* 기본 마크다운을 미리보기 HTML로 변환
* @param {string} markdown - 마크다운 문자열
* @returns {string} 미리보기 HTML
*/
const renderMarkdown = (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) {
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(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
index += 1
continue
}
const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)
if (headingMatch) {
const level = headingMatch[1].length
blocks.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
index += 1
continue
}
if (trimmedLine.startsWith('> ')) {
const quotes = []
while (index < lines.length && lines[index].trim().startsWith('> ')) {
quotes.push(lines[index].trim().replace(/^>\s?/, ''))
index += 1
}
blocks.push(`<blockquote>${quotes.map((quote) => `<p>${parseInlineMarkdown(quote)}</p>`).join('')}</blockquote>`)
continue
}
if (/^- /.test(trimmedLine)) {
const items = []
while (index < lines.length && /^- /.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^- /, ''))
index += 1
}
blocks.push(renderList(items, 'ul'))
continue
}
if (/^\d+\. /.test(trimmedLine)) {
const items = []
while (index < lines.length && /^\d+\. /.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^\d+\. /, ''))
index += 1
}
blocks.push(renderList(items, 'ol'))
continue
}
const paragraphs = []
while (
index < lines.length &&
lines[index].trim() &&
!lines[index].trim().startsWith('```') &&
!lines[index].trim().match(/^(#{1,3})\s+(.+)$/) &&
!lines[index].trim().startsWith('> ') &&
!/^- /.test(lines[index].trim()) &&
!/^\d+\. /.test(lines[index].trim())
) {
paragraphs.push(lines[index].trim())
index += 1
}
blocks.push(`<p>${parseInlineMarkdown(paragraphs.join(' '))}</p>`)
}
return blocks.join('')
}
const previewHtml = computed(() => renderMarkdown(props.content))
</script>
<template>
<article
class="admin-markdown-preview post-prose min-h-[28rem] rounded border border-line bg-white px-5 py-4 text-sm leading-7"
v-html="previewHtml"
/>
</template>