공개 마크다운 블록 파서 분리
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
getImageAltAttribute,
|
getImageAltAttribute,
|
||||||
getImageDisplayCaption,
|
getImageDisplayCaption
|
||||||
isImageUrl,
|
|
||||||
parseImageMarkdownLine
|
|
||||||
} from '../../lib/markdown-image.js'
|
} from '../../lib/markdown-image.js'
|
||||||
import {
|
import {
|
||||||
getPostBodyPreviewThumbnailUrl,
|
getPostBodyPreviewThumbnailUrl,
|
||||||
@@ -30,30 +28,20 @@ import {
|
|||||||
parseOrderedListMarker,
|
parseOrderedListMarker,
|
||||||
stripListMarker
|
stripListMarker
|
||||||
} from '../../lib/markdown-live-edit.js'
|
} from '../../lib/markdown-live-edit.js'
|
||||||
import { buildCodeBlockLines, buildCodeFenceOpener, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
|
import { buildCodeBlockLines, buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
||||||
import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js'
|
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
|
||||||
import { parseMarkdownTableBlock } from '../../lib/markdown-table.js'
|
|
||||||
import {
|
|
||||||
collectFencedLines,
|
|
||||||
parseBookmarkMeta,
|
|
||||||
parseMediaMeta,
|
|
||||||
parseSignupMeta
|
|
||||||
} from '../../lib/markdown-fenced-meta.js'
|
|
||||||
import {
|
import {
|
||||||
buildCalloutOpenerLine,
|
buildCalloutOpenerLine,
|
||||||
CALLOUT_BACKGROUND_OPTIONS,
|
CALLOUT_BACKGROUND_OPTIONS
|
||||||
parseCalloutOptions
|
|
||||||
} from '../../lib/markdown-callout.js'
|
} from '../../lib/markdown-callout.js'
|
||||||
import {
|
import {
|
||||||
formatQuoteLine,
|
formatQuoteLine,
|
||||||
getQuoteContentStartLine,
|
getQuoteContentStartLine,
|
||||||
getQuoteLineBody,
|
|
||||||
getQuoteLineEntries,
|
getQuoteLineEntries,
|
||||||
isQuoteMarkerLine,
|
normalizeQuoteEditableValue
|
||||||
normalizeQuoteEditableValue,
|
|
||||||
parseQuoteOptions
|
|
||||||
} from '../../lib/markdown-quote.js'
|
} from '../../lib/markdown-quote.js'
|
||||||
import { getInArticleAdAfterBlockIds } from '../../lib/content-in-article-ad.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 { createHeadingIdFactory } from '../../lib/markdown-toc.js'
|
||||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||||
@@ -107,7 +95,6 @@ const emit = defineEmits([
|
|||||||
'content-replace'
|
'content-replace'
|
||||||
])
|
])
|
||||||
|
|
||||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
|
||||||
/** @type {string} 라이브 이미지·갤러리 드래그 payload MIME */
|
/** @type {string} 라이브 이미지·갤러리 드래그 payload MIME */
|
||||||
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
|
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
|
||||||
/** @type {string} 본문 리스트 마커 색상 */
|
/** @type {string} 본문 리스트 마커 색상 */
|
||||||
@@ -136,125 +123,6 @@ const rawEditingSourceLines = ref(new Set())
|
|||||||
/** @type {number} 문단 분리 연속 호출 방지 */
|
/** @type {number} 문단 분리 연속 호출 방지 */
|
||||||
let lastParagraphSplitAt = 0
|
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 - 렌더링 블록
|
* @param {Object} block - 렌더링 블록
|
||||||
@@ -262,315 +130,6 @@ const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageU
|
|||||||
*/
|
*/
|
||||||
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
|
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 blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||||
const normalizedInArticleAdCode = computed(() => String(props.inArticleAdCode || '').trim())
|
const normalizedInArticleAdCode = computed(() => String(props.inArticleAdCode || '').trim())
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
| lib/recommended-site-icon-url.js | 추천 사이트 아이콘 URL 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) |
|
| lib/recommended-site-icon-url.js | 추천 사이트 아이콘 URL 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) |
|
||||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
||||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||||
|
| lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 |
|
||||||
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
|
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
|
||||||
| lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출 |
|
| lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출 |
|
||||||
| lib/markdown-html-block.js | 게시물 본문 HTML 블록의 parser 기반 허용 태그·속성 sanitizer, 실행 URL·인라인 style 제거와 새 창 링크 rel 보정 |
|
| lib/markdown-html-block.js | 게시물 본문 HTML 블록의 parser 기반 허용 태그·속성 sanitizer, 실행 URL·인라인 style 제거와 새 창 링크 rel 보정 |
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
| tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 |
|
| tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 |
|
||||||
| tests/content-in-article-ad.test.js | 공개 본문 인아티클 광고 삽입 위치 계산 단위 회귀 테스트 |
|
| tests/content-in-article-ad.test.js | 공개 본문 인아티클 광고 삽입 위치 계산 단위 회귀 테스트 |
|
||||||
| tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 |
|
| tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 |
|
||||||
|
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
||||||
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
|
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
|
||||||
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |
|
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |
|
||||||
| tests/multipart-file-upload.test.js | multipart 스트리밍·제한 단위 회귀 테스트 |
|
| tests/multipart-file-upload.test.js | multipart 스트리밍·제한 단위 회귀 테스트 |
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||||
- 공개 게시물 오른쪽 목차는 H1~H3 제목 표시 텍스트에서 링크·굵게·기울임·인라인 코드 같은 마크다운 장식만 제거하고 `~` 같은 일반 제목 문자는 보존한다.
|
- 공개 게시물 오른쪽 목차는 H1~H3 제목 표시 텍스트에서 링크·굵게·기울임·인라인 코드 같은 마크다운 장식만 제거하고 `~` 같은 일반 제목 문자는 보존한다.
|
||||||
- 공개 Markdown 인라인 렌더러는 단일 루트와 `display: contents`를 사용해 사용자 화면 DOM의 Vue fragment 주석 반복을 줄이고, 기존 인라인 흐름은 유지한다. 링크·굵게·기울임·색상 토큰 내부의 굵게·기울임·링크·인라인 코드·색상·첨자 문법은 재귀적으로 렌더링한다. 여러 줄 문단은 첫 줄과 이후 줄을 분리해 첫 줄에 불필요한 `br` 조건 분기를 두지 않는다.
|
- 공개 Markdown 인라인 렌더러는 단일 루트와 `display: contents`를 사용해 사용자 화면 DOM의 Vue fragment 주석 반복을 줄이고, 기존 인라인 흐름은 유지한다. 링크·굵게·기울임·색상 토큰 내부의 굵게·기울임·링크·인라인 코드·색상·첨자 문법은 재귀적으로 렌더링한다. 여러 줄 문단은 첫 줄과 이후 줄을 분리해 첫 줄에 불필요한 `br` 조건 분기를 두지 않는다.
|
||||||
- 공개 Markdown 본문 블록 렌더링은 `ContentMarkdownBlockGroup`으로 블록 단위 루트를 정리하고, 인아티클 광고는 별도 `v-if` 대신 렌더 블록 목록에 삽입한다.
|
- 공개 Markdown 본문 블록 파싱은 `lib/markdown-block-parser.js`에서 수행하고, 본문 블록 렌더링은 `ContentMarkdownBlockGroup`으로 블록 단위 루트를 정리한다. 인아티클 광고는 별도 `v-if` 대신 렌더 블록 목록에 삽입한다.
|
||||||
- Markdown 리스트 마커는 항목이 여러 줄로 줄바꿈되어도 항목 전체 높이 중앙이 아니라 첫 줄 기준으로 정렬한다.
|
- Markdown 리스트 마커는 항목이 여러 줄로 줄바꿈되어도 항목 전체 높이 중앙이 아니라 첫 줄 기준으로 정렬한다.
|
||||||
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다. 표 헤더와 셀 내부의 인라인 링크·굵게·기울임·인라인 코드를 렌더링하고, 셀 내부 `<br>` 또는 `<br/>`은 줄바꿈으로 표시한다. 링크 텍스트 안의 `**굵게**` 같은 중첩 인라인 문법도 렌더링한다.
|
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다. 표 헤더와 셀 내부의 인라인 링크·굵게·기울임·인라인 코드를 렌더링하고, 셀 내부 `<br>` 또는 `<br/>`은 줄바꿈으로 표시한다. 링크 텍스트 안의 `**굵게**` 같은 중첩 인라인 문법도 렌더링한다.
|
||||||
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다. 인용 본문 안의 `::red[**텍스트**]` 같은 색상·굵게·링크 등 인라인 문법은 일반 문단과 같은 규칙으로 렌더링한다.
|
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다. 인용 본문 안의 `::red[**텍스트**]` 같은 색상·굵게·링크 등 인라인 문법은 일반 문단과 같은 규칙으로 렌더링한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.54
|
||||||
|
|
||||||
|
- 공개 Markdown 렌더러의 블록 파싱 로직을 `lib/markdown-block-parser.js`로 분리.
|
||||||
|
- 제목·문단·이미지·fenced 블록·인용·목록·코드·표 변환 단위 테스트를 추가.
|
||||||
|
- 토글 블록의 기본 펼침 상태가 파서 결과에 보존되도록 정리.
|
||||||
|
|
||||||
## v1.11.53
|
## v1.11.53
|
||||||
|
|
||||||
- 관리자 블록 컨텍스트의 fenced 블록 시작·종료 줄 탐색 helper를 `lib/markdown-fenced-meta.js`로 통합.
|
- 관리자 블록 컨텍스트의 fenced 블록 시작·종료 줄 탐색 helper를 `lib/markdown-fenced-meta.js`로 통합.
|
||||||
|
|||||||
448
lib/markdown-block-parser.js
Normal file
448
lib/markdown-block-parser.js
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js'
|
||||||
|
import { parseCodeFenceLine } from './markdown-code-block.js'
|
||||||
|
import { parseToggleOpenerLine } from './markdown-toggle.js'
|
||||||
|
import { parseMarkdownTableBlock } from './markdown-table.js'
|
||||||
|
import {
|
||||||
|
collectFencedLines,
|
||||||
|
parseBookmarkMeta,
|
||||||
|
parseMediaMeta,
|
||||||
|
parseSignupMeta
|
||||||
|
} from './markdown-fenced-meta.js'
|
||||||
|
import { parseCalloutOptions } from './markdown-callout.js'
|
||||||
|
import {
|
||||||
|
getQuoteLineBody,
|
||||||
|
isQuoteMarkerLine,
|
||||||
|
parseQuoteOptions
|
||||||
|
} from './markdown-quote.js'
|
||||||
|
|
||||||
|
/** @type {string} 빈 문단을 보존하는 레거시 마커 */
|
||||||
|
export const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록에 원본 마크다운 줄 범위를 붙인다.
|
||||||
|
* @param {Object} block - 블록
|
||||||
|
* @param {number} startLine - 시작 줄(0-based)
|
||||||
|
* @param {number} endLine - 끝 줄(0-based)
|
||||||
|
* @returns {Object} 블록
|
||||||
|
*/
|
||||||
|
export const attachSourceRange = (block, startLine, endLine) => {
|
||||||
|
block.meta = {
|
||||||
|
...(block.meta && typeof block.meta === 'object' ? block.meta : {}),
|
||||||
|
startLine,
|
||||||
|
endLine: endLine ?? startLine
|
||||||
|
}
|
||||||
|
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마크다운 렌더링 블록을 생성한다.
|
||||||
|
* @param {string} type - 블록 타입
|
||||||
|
* @param {string|Array<string>} text - 블록 텍스트
|
||||||
|
* @param {number|null} level - 제목 레벨
|
||||||
|
* @param {string} id - 블록 ID
|
||||||
|
* @param {Object} options - 추가 블록 옵션
|
||||||
|
* @returns {Object} 블록
|
||||||
|
*/
|
||||||
|
export const createMarkdownBlock = (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',
|
||||||
|
defaultOpen: options.defaultOpen === true,
|
||||||
|
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} 블록 시작 여부
|
||||||
|
*/
|
||||||
|
export 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 {string} markdown - 마크다운 문자열
|
||||||
|
* @returns {Array<Object>} 블록 목록
|
||||||
|
*/
|
||||||
|
export 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(createMarkdownBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }), startLine, startLine))
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
const startLine = index
|
||||||
|
blocks.push(attachSourceRange(createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock(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(
|
||||||
|
createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock(blockType, '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
|
||||||
|
startLine,
|
||||||
|
endLine
|
||||||
|
))
|
||||||
|
index = nextIndex
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStandaloneImageUrlLine(trimmedLine)) {
|
||||||
|
const startLine = index
|
||||||
|
blocks.push(attachSourceRange(createMarkdownBlock('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(createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('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(createMarkdownBlock('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(
|
||||||
|
createMarkdownBlock('list', items, null, `block-${blocks.length}`, { ordered: true, listNumbers }),
|
||||||
|
startLine,
|
||||||
|
index - 1
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(attachSourceRange(
|
||||||
|
createMarkdownBlock('paragraph', cleanParagraphLine(line), null, `block-${blocks.length}`),
|
||||||
|
index,
|
||||||
|
index
|
||||||
|
))
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.53",
|
"version": "1.11.54",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.53",
|
"version": "1.11.54",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.53",
|
"version": "1.11.54",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
87
tests/markdown-block-parser.test.js
Normal file
87
tests/markdown-block-parser.test.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
BLANK_PARAGRAPH_MARKER,
|
||||||
|
isMarkdownBlockStart,
|
||||||
|
parseMarkdownBlocks
|
||||||
|
} from '../lib/markdown-block-parser.js'
|
||||||
|
|
||||||
|
test('기본 마크다운 행을 렌더링 블록으로 변환한다', () => {
|
||||||
|
const blocks = parseMarkdownBlocks([
|
||||||
|
'# 제목',
|
||||||
|
'',
|
||||||
|
'본문 ',
|
||||||
|
BLANK_PARAGRAPH_MARKER,
|
||||||
|
'---'
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
assert.deepEqual(blocks.map((block) => block.type), ['heading', 'spacer', 'paragraph', 'spacer', 'divider'])
|
||||||
|
assert.equal(blocks[0].text, '제목')
|
||||||
|
assert.equal(blocks[0].level, 1)
|
||||||
|
assert.equal(blocks[2].text, '본문')
|
||||||
|
assert.equal(blocks[3].meta.legacy, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fenced 블록을 타입별 메타와 원본 줄 범위로 변환한다', () => {
|
||||||
|
const blocks = parseMarkdownBlocks([
|
||||||
|
':::gallery',
|
||||||
|
'{size=235}',
|
||||||
|
'',
|
||||||
|
':::',
|
||||||
|
':::callout bg=red emoji=none',
|
||||||
|
'주의',
|
||||||
|
':::',
|
||||||
|
':::toggle open 자세히',
|
||||||
|
'내용',
|
||||||
|
':::'
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
assert.deepEqual(blocks.map((block) => block.type), ['gallery', 'callout', 'toggle'])
|
||||||
|
assert.equal(blocks[0].images.length, 2)
|
||||||
|
assert.equal(blocks[0].images[0].pixelWidth, 235)
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
startLine: blocks[0].meta.startLine,
|
||||||
|
endLine: blocks[0].meta.endLine
|
||||||
|
},
|
||||||
|
{ startLine: 0, endLine: 3 }
|
||||||
|
)
|
||||||
|
assert.equal(blocks[1].calloutBackground, 'red')
|
||||||
|
assert.equal(blocks[1].calloutEmojiEnabled, false)
|
||||||
|
assert.equal(blocks[2].title, '자세히')
|
||||||
|
assert.equal(blocks[2].defaultOpen, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('인용·목록·코드·표 블록을 변환한다', () => {
|
||||||
|
const blocks = parseMarkdownBlocks([
|
||||||
|
'> [!bg=blue]',
|
||||||
|
'> 인용',
|
||||||
|
'- 하나',
|
||||||
|
'- 둘',
|
||||||
|
'1. 첫째',
|
||||||
|
'3. 셋째',
|
||||||
|
'```js line-numbers',
|
||||||
|
'const value = 1',
|
||||||
|
'```',
|
||||||
|
'| A | B |',
|
||||||
|
'| --- | ---: |',
|
||||||
|
'| a | b |'
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
assert.deepEqual(blocks.map((block) => block.type), ['quote', 'list', 'list', 'code', 'table'])
|
||||||
|
assert.equal(blocks[0].quoteBackground, 'blue')
|
||||||
|
assert.equal(blocks[0].text, '인용')
|
||||||
|
assert.deepEqual(blocks[1].text, ['하나', '둘'])
|
||||||
|
assert.equal(blocks[2].ordered, true)
|
||||||
|
assert.deepEqual(blocks[2].listNumbers, [1, 3])
|
||||||
|
assert.equal(blocks[3].codeLanguage, 'js')
|
||||||
|
assert.equal(blocks[3].codeShowLineNumbers, true)
|
||||||
|
assert.deepEqual(blocks[4].meta.alignments, ['left', 'right'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('블록 시작 줄을 판별한다', () => {
|
||||||
|
assert.equal(isMarkdownBlockStart(':::gallery'), true)
|
||||||
|
assert.equal(isMarkdownBlockStart('```html'), true)
|
||||||
|
assert.equal(isMarkdownBlockStart(''), true)
|
||||||
|
assert.equal(isMarkdownBlockStart('일반 문단'), false)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user