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 = '' /** * 블록에 원본 마크다운 줄 범위를 붙인다. * @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} 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} 블록 목록 */ 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 }