공개 마크다운 블록 파서 분리
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
<script setup>
|
||||
import {
|
||||
getImageAltAttribute,
|
||||
getImageDisplayCaption,
|
||||
isImageUrl,
|
||||
parseImageMarkdownLine
|
||||
getImageDisplayCaption
|
||||
} from '../../lib/markdown-image.js'
|
||||
import {
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
@@ -30,30 +28,20 @@ import {
|
||||
parseOrderedListMarker,
|
||||
stripListMarker
|
||||
} from '../../lib/markdown-live-edit.js'
|
||||
import { buildCodeBlockLines, buildCodeFenceOpener, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
|
||||
import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js'
|
||||
import { parseMarkdownTableBlock } from '../../lib/markdown-table.js'
|
||||
import {
|
||||
collectFencedLines,
|
||||
parseBookmarkMeta,
|
||||
parseMediaMeta,
|
||||
parseSignupMeta
|
||||
} from '../../lib/markdown-fenced-meta.js'
|
||||
import { buildCodeBlockLines, buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
||||
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
|
||||
import {
|
||||
buildCalloutOpenerLine,
|
||||
CALLOUT_BACKGROUND_OPTIONS,
|
||||
parseCalloutOptions
|
||||
CALLOUT_BACKGROUND_OPTIONS
|
||||
} from '../../lib/markdown-callout.js'
|
||||
import {
|
||||
formatQuoteLine,
|
||||
getQuoteContentStartLine,
|
||||
getQuoteLineBody,
|
||||
getQuoteLineEntries,
|
||||
isQuoteMarkerLine,
|
||||
normalizeQuoteEditableValue,
|
||||
parseQuoteOptions
|
||||
normalizeQuoteEditableValue
|
||||
} from '../../lib/markdown-quote.js'
|
||||
import { getInArticleAdAfterBlockIds } from '../../lib/content-in-article-ad.js'
|
||||
import { parseMarkdownBlocks } from '../../lib/markdown-block-parser.js'
|
||||
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
|
||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
@@ -107,7 +95,6 @@ const emit = defineEmits([
|
||||
'content-replace'
|
||||
])
|
||||
|
||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
/** @type {string} 라이브 이미지·갤러리 드래그 payload MIME */
|
||||
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
|
||||
/** @type {string} 본문 리스트 마커 색상 */
|
||||
@@ -136,125 +123,6 @@ const rawEditingSourceLines = ref(new Set())
|
||||
/** @type {number} 문단 분리 연속 호출 방지 */
|
||||
let lastParagraphSplitAt = 0
|
||||
|
||||
/**
|
||||
* 마크다운 블록을 생성
|
||||
* @param {string} type - 블록 타입
|
||||
* @param {string|Array<string>} text - 블록 텍스트
|
||||
* @param {number|null} level - 제목 레벨
|
||||
* @param {string} id - 블록 ID
|
||||
* @param {Object} options - 추가 블록 옵션
|
||||
* @returns {Object} 블록
|
||||
*/
|
||||
/**
|
||||
* 블록에 원본 마크다운 줄 범위를 붙인다.
|
||||
* @param {Object} block - 블록
|
||||
* @param {number} startLine - 시작 줄(0-based)
|
||||
* @param {number} endLine - 끝 줄(0-based)
|
||||
* @returns {Object} 블록
|
||||
*/
|
||||
const attachSourceRange = (block, startLine, endLine) => {
|
||||
block.meta = {
|
||||
...(block.meta && typeof block.meta === 'object' ? block.meta : {}),
|
||||
startLine,
|
||||
endLine: endLine ?? startLine
|
||||
}
|
||||
|
||||
return block
|
||||
}
|
||||
|
||||
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
caption: options.caption || '',
|
||||
useAlt: options.useAlt === true,
|
||||
title: options.title || '',
|
||||
variant: options.variant || '',
|
||||
ordered: options.ordered || false,
|
||||
listNumbers: Array.isArray(options.listNumbers) ? options.listNumbers : [],
|
||||
width: options.width || 'regular',
|
||||
pixelWidth: Math.round(Number(options.pixelWidth) || 0) || null,
|
||||
images: options.images || [],
|
||||
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
|
||||
calloutEmojiEnabled: options.calloutEmojiEnabled === true,
|
||||
calloutEmoji: options.calloutEmoji || '💡',
|
||||
calloutBackground: options.calloutBackground || 'blue',
|
||||
quoteBackground: options.quoteBackground || 'gray',
|
||||
codeLanguage: options.codeLanguage || '',
|
||||
codeShowLineNumbers: options.codeShowLineNumbers === true
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 이미지 데이터로 변환
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {Object|null} 이미지 데이터
|
||||
*/
|
||||
const parseImageLine = (line) => parseImageMarkdownLine(line)
|
||||
|
||||
/**
|
||||
* 불릿 목록 마커 줄인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isBulletListMarkerLine = (line) => {
|
||||
const trimmed = String(line ?? '').trim()
|
||||
return trimmed === '-' || /^-\s/.test(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
||||
* @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 === ':::video' ||
|
||||
trimmedLine === ':::audio' ||
|
||||
trimmedLine === ':::file' ||
|
||||
trimmedLine === ':::link' ||
|
||||
trimmedLine === ':::iframe' ||
|
||||
trimmedLine === ':::html' ||
|
||||
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 {string} 정리된 문단 행
|
||||
*/
|
||||
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
|
||||
|
||||
/**
|
||||
* 단독 URL 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean} 단독 URL 여부
|
||||
*/
|
||||
const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim())
|
||||
|
||||
/**
|
||||
* 단독 이미지 URL 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean} 이미지 URL 여부
|
||||
*/
|
||||
const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageUrl(line)
|
||||
|
||||
/**
|
||||
* 빈 줄 공백 블록 높이를 반환한다.
|
||||
* @param {Object} block - 렌더링 블록
|
||||
@@ -262,315 +130,6 @@ const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageU
|
||||
*/
|
||||
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
|
||||
|
||||
/**
|
||||
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
||||
* @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) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!trimmedLine) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('spacer', '', null, `block-${blocks.length}`), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === '>>>') {
|
||||
const startLine = index
|
||||
const contentLines = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== '<<<') {
|
||||
contentLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('quote', contentLines.join('\n'), null, `block-${blocks.length}`, { variant: 'alt' }),
|
||||
startLine,
|
||||
index - 1
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::bookmark') {
|
||||
const startLine = index
|
||||
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
|
||||
|
||||
if (bookmarkMeta.url) {
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }),
|
||||
startLine,
|
||||
endLine
|
||||
))
|
||||
}
|
||||
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::signup') {
|
||||
const startLine = index
|
||||
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const signupMeta = parseSignupMeta(contentLines.join('\n'))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }),
|
||||
startLine,
|
||||
endLine
|
||||
))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const startLine = index
|
||||
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const images = []
|
||||
|
||||
contentLines.forEach((contentLine) => {
|
||||
const image = parseImageLine(contentLine)
|
||||
if (image) {
|
||||
images.push(image)
|
||||
}
|
||||
})
|
||||
|
||||
blocks.push(attachSourceRange(createBlock('gallery', '', null, `block-${blocks.length}`, {
|
||||
images
|
||||
}), startLine, endLine))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if ([':::video', ':::audio', ':::file'].includes(trimmedLine)) {
|
||||
const startLine = index
|
||||
const blockType = trimmedLine.replace(':::', '')
|
||||
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const mediaMeta = parseMediaMeta(contentLines.join('\n'))
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock(blockType, '', null, `block-${blocks.length}`, { meta: mediaMeta }),
|
||||
startLine,
|
||||
endLine
|
||||
))
|
||||
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith(':::callout')) {
|
||||
const startLine = index
|
||||
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)),
|
||||
startLine,
|
||||
endLine
|
||||
))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith(':::toggle')) {
|
||||
const startLine = index
|
||||
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const toggleOptions = parseToggleOpenerLine(trimmedLine)
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, toggleOptions),
|
||||
startLine,
|
||||
endLine
|
||||
))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if ([':::link', ':::iframe', ':::html'].includes(trimmedLine)) {
|
||||
const startLine = index
|
||||
const blockType = trimmedLine.replace(':::', '')
|
||||
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock(blockType, '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
|
||||
startLine,
|
||||
endLine
|
||||
))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (isStandaloneImageUrlLine(trimmedLine)) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, {
|
||||
url: trimmedLine,
|
||||
width: 'regular',
|
||||
pixelWidth: null
|
||||
}), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, image), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const startLine = index
|
||||
const fenceOptions = parseCodeFenceLine(trimmedLine) || { language: '', showLineNumbers: false }
|
||||
const codeLines = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
||||
codeLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (index >= lines.length) {
|
||||
index = startLine
|
||||
} else {
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, {
|
||||
codeLanguage: fenceOptions.language,
|
||||
codeShowLineNumbers: fenceOptions.showLineNumbers
|
||||
}),
|
||||
startLine,
|
||||
index
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmedLine === '---') {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('divider', '', null, `block-${blocks.length}`),
|
||||
startLine,
|
||||
startLine
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/)
|
||||
|
||||
if (headingMatch) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`),
|
||||
startLine,
|
||||
startLine
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const tableBlock = parseMarkdownTableBlock(lines, index)
|
||||
if (tableBlock) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('table', '', null, `block-${blocks.length}`, {
|
||||
meta: {
|
||||
headers: tableBlock.headers,
|
||||
alignments: tableBlock.alignments,
|
||||
rows: tableBlock.rows
|
||||
}
|
||||
}),
|
||||
startLine,
|
||||
tableBlock.endLine
|
||||
))
|
||||
index = tableBlock.nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (isQuoteMarkerLine(line)) {
|
||||
const startLine = index
|
||||
const rawQuoteLines = []
|
||||
|
||||
while (index < lines.length && isQuoteMarkerLine(lines[index])) {
|
||||
rawQuoteLines.push(getQuoteLineBody(lines[index]))
|
||||
index += 1
|
||||
}
|
||||
|
||||
const quoteOptions = parseQuoteOptions(rawQuoteLines[0])
|
||||
const quoteLines = quoteOptions ? rawQuoteLines.slice(1) : rawQuoteLines
|
||||
const contentStartLine = startLine + (quoteOptions ? 1 : 0)
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('quote', (quoteLines.length ? quoteLines : ['']).join('\n'), null, `block-${blocks.length}`, {
|
||||
...(quoteOptions || {}),
|
||||
meta: {
|
||||
quoteContentStartLine: contentStartLine
|
||||
}
|
||||
}),
|
||||
startLine,
|
||||
index - 1
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
if (isBulletListMarkerLine(line)) {
|
||||
const startLine = index
|
||||
const items = []
|
||||
|
||||
while (index < lines.length && isBulletListMarkerLine(lines[index])) {
|
||||
items.push(lines[index].trim().replace(/^-\s?/, ''))
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(attachSourceRange(createBlock('list', items, null, `block-${blocks.length}`), startLine, index - 1))
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d+\.\s*/.test(trimmedLine)) {
|
||||
const startLine = index
|
||||
const items = []
|
||||
const listNumbers = []
|
||||
|
||||
while (index < lines.length && /^\d+\.\s*/.test(lines[index].trim())) {
|
||||
const match = lines[index].trim().match(/^(\d+)\.\s*(.*)$/)
|
||||
listNumbers.push(Number(match[1]))
|
||||
items.push(String(match[2] ?? '').trim())
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('list', items, null, `block-${blocks.length}`, { ordered: true, listNumbers }),
|
||||
startLine,
|
||||
index - 1
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('paragraph', cleanParagraphLine(line), null, `block-${blocks.length}`),
|
||||
index,
|
||||
index
|
||||
))
|
||||
index += 1
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
const normalizedInArticleAdCode = computed(() => String(props.inArticleAdCode || '').trim())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user