153 lines
3.8 KiB
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
|
|
/**
|
|
* 인라인 마크다운 변환
|
|
* @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>
|