import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js' import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from './markdown-callout.js' import { parseCodeFenceLine } from './markdown-code-block.js' import { parseToggleOpenerLine } from './markdown-toggle.js' import { getQuoteLineBody, isQuoteMarkerLine, parseQuoteOptions } from './markdown-quote.js' import { findFencedBlockEnd, findFencedBlockStart, findFencedBlockStartBy } from './markdown-fenced-meta.js' /** * 단독 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[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @returns {{ kind: 'quote', startLine: number, endLine: number, quoteBackground: string, hasQuoteOptions: boolean }|null} */ const resolveQuoteBlock = (lines, currentLine) => { if (!isQuoteMarkerLine(lines[currentLine] || '')) { return null } let startLine = currentLine let endLine = currentLine while (startLine > 0 && isQuoteMarkerLine(lines[startLine - 1] || '')) { startLine -= 1 } while (endLine < lines.length - 1 && isQuoteMarkerLine(lines[endLine + 1] || '')) { endLine += 1 } const firstQuoteBody = getQuoteLineBody(lines[startLine] || '') const quoteOptions = parseQuoteOptions(firstQuoteBody) return { kind: 'quote', startLine, endLine, quoteBackground: quoteOptions?.quoteBackground || 'gray', hasQuoteOptions: Boolean(quoteOptions) } } /** * 갤러리 fenced 블록을 파싱한다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @returns {{ kind: 'gallery', startLine: number, endLine: number, images: Array }|null} */ const resolveGalleryBlock = (lines, currentLine) => { const galleryStart = findFencedBlockStart(lines, currentLine, ':::gallery') if (galleryStart === -1) { return null } const galleryEnd = findFencedBlockEnd(lines, galleryStart) if (galleryEnd === -1 || currentLine > galleryEnd) { return null } return { kind: 'gallery', startLine: galleryStart, endLine: galleryEnd, selectedImageIndex: currentLine > galleryStart && currentLine < galleryEnd ? currentLine - galleryStart - 1 : null, images: lines .slice(galleryStart + 1, galleryEnd) .map(parseImageMarkdownLine) .filter(Boolean) } } /** * 링크·iframe·HTML fenced 블록을 파싱한다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @returns {{ kind: 'link'|'iframe'|'html', startLine: number, endLine: number, url: string }|null} */ const resolveHtmlFamilyBlock = (lines, currentLine) => { const blockTypes = [ { opener: ':::link', kind: 'link' }, { opener: ':::iframe', kind: 'iframe' }, { opener: ':::html', kind: 'html' } ] for (const blockType of blockTypes) { const blockStart = findFencedBlockStart(lines, currentLine, blockType.opener) if (blockStart === -1) { continue } const blockEnd = findFencedBlockEnd(lines, blockStart) if (blockEnd === -1 || currentLine > blockEnd) { continue } return { kind: blockType.kind, startLine: blockStart, endLine: blockEnd, url: lines.slice(blockStart + 1, blockEnd).join('\n').trim() } } return null } /** * 콜아웃 fenced 블록을 파싱한다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @returns {{ kind: 'callout', startLine: number, endLine: number, calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string }|null} */ const resolveCalloutBlock = (lines, currentLine) => { const calloutStart = findFencedBlockStartBy(lines, currentLine, (line) => line.startsWith(':::callout')) if (calloutStart === -1) { return null } const calloutEnd = findFencedBlockEnd(lines, calloutStart) if (calloutEnd === -1 || currentLine > calloutEnd) { return null } return { kind: 'callout', startLine: calloutStart, endLine: calloutEnd, ...parseCalloutOptions(lines[calloutStart]) } } /** * 토글 fenced 블록을 파싱한다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @returns {{ kind: 'toggle', startLine: number, endLine: number, title: string, defaultOpen: boolean }|null} */ const resolveToggleBlock = (lines, currentLine) => { const toggleStart = findFencedBlockStartBy(lines, currentLine, (line) => line.startsWith(':::toggle')) if (toggleStart === -1) { return null } const toggleEnd = findFencedBlockEnd(lines, toggleStart) if (toggleEnd === -1 || currentLine > toggleEnd) { return null } return { kind: 'toggle', startLine: toggleStart, endLine: toggleEnd, ...parseToggleOpenerLine(lines[toggleStart]) } } /** * 코드 fenced 블록 종료 줄 인덱스를 찾는다. * @param {string[]} lines - 본문 줄 목록 * @param {number} startLine - 시작 줄 * @returns {number} 종료 줄 또는 -1 */ const findCodeFenceEnd = (lines, startLine) => { for (let index = startLine + 1; index < lines.length; index += 1) { if ((lines[index] || '').trim().startsWith('```')) { return index } } return -1 } /** * 코드 fenced 블록을 파싱한다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @returns {{ kind: 'code', startLine: number, endLine: number, language: string, showLineNumbers: boolean }|null} */ const resolveCodeBlock = (lines, currentLine) => { for (let index = 0; index <= currentLine; index += 1) { if (!(lines[index] || '').trim().startsWith('```')) { continue } const codeEnd = findCodeFenceEnd(lines, index) if (codeEnd === -1) { return null } if (currentLine >= index && currentLine <= codeEnd) { const options = parseCodeFenceLine(lines[index]) || { language: '', showLineNumbers: false } return { kind: 'code', startLine: index, endLine: codeEnd, language: options.language, showLineNumbers: options.showLineNumbers } } index = codeEnd } return null } /** * 커서 줄 기준 활성 블록 컨텍스트를 반환한다. * @param {string} markdown - 본문 마크다운 * @param {number} lineIndex - 현재 줄(0-based) * @returns {Object|null} 블록 컨텍스트 */ export const resolveActiveBlockContext = (markdown, lineIndex) => { const lines = String(markdown || '').split('\n') const currentLine = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1)) const code = resolveCodeBlock(lines, currentLine) if (code) { return code } const activeImage = parseImageMarkdownLine(lines[currentLine] || '') const activeImageUrl = String(lines[currentLine] || '').trim() if (activeImage) { return { kind: 'image', startLine: currentLine, endLine: currentLine, images: [activeImage] } } if (isStandaloneImageUrlLine(activeImageUrl)) { return { kind: 'image', startLine: currentLine, endLine: currentLine, images: [{ url: activeImageUrl, width: 'regular', caption: '', useAlt: false }] } } const gallery = resolveGalleryBlock(lines, currentLine) if (gallery) { return gallery } const quote = resolveQuoteBlock(lines, currentLine) if (quote) { return quote } const callout = resolveCalloutBlock(lines, currentLine) if (callout) { return callout } const toggle = resolveToggleBlock(lines, currentLine) if (toggle) { return toggle } return resolveHtmlFamilyBlock(lines, currentLine) }