import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js' import { CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS, parseCalloutOptions } from './markdown-callout.js' import { parseCodeFenceLine } from './markdown-code-block.js' import { parseToggleOpenerLine } from './markdown-toggle.js' /** * fenced 블록 시작 줄 인덱스를 찾는다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @param {string} opener - 시작 토큰 * @returns {number} 시작 줄 또는 -1 */ const findFencedBlockStart = (lines, currentLine, opener) => { for (let index = currentLine; index >= 0; index -= 1) { if ((lines[index] || '').trim() === opener) { return index } if ((lines[index] || '').trim() === ':::') { break } } return -1 } /** * 조건에 맞는 fenced 블록 시작 줄 인덱스를 찾는다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @param {(line: string) => boolean} predicate - 시작 줄 판별 함수 * @returns {number} 시작 줄 또는 -1 */ const findFencedBlockStartBy = (lines, currentLine, predicate) => { for (let index = currentLine; index >= 0; index -= 1) { if (predicate((lines[index] || '').trim())) { return index } if ((lines[index] || '').trim() === ':::') { break } } return -1 } /** * fenced 블록 종료 줄 인덱스를 찾는다. * @param {string[]} lines - 본문 줄 목록 * @param {number} startLine - 시작 줄 * @returns {number} 종료 줄 또는 -1 */ const findFencedBlockEnd = (lines, startLine) => { for (let index = startLine + 1; index < lines.length; index += 1) { if ((lines[index] || '').trim() === ':::') { return index } } return -1 } /** * 단독 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} line - 마크다운 줄 * @returns {boolean} 인용 줄 여부 */ const isQuoteMarkerLine = (line) => { const trimmed = String(line ?? '').trim() return trimmed === '>' || /^>\s/.test(trimmed) } /** * 인용 마커를 제거한 본문을 반환한다. * @param {string} line - 마크다운 줄 * @returns {string} 인용 본문 */ const getQuoteLineBody = (line) => String(line ?? '').trim().replace(/^>\s?/, '') /** * 인용 옵션 줄을 파싱한다. * @param {string} value - 인용 본문 줄 * @returns {{ quoteBackground: string }|null} 인용 옵션 */ const parseQuoteOptionsLine = (value) => { const raw = String(value ?? '').trim() const bracketMatch = raw.match(/^\[!(.+)\]$/) const braceMatch = raw.match(/^\{(.+)\}$/) const optionSource = bracketMatch?.[1] || braceMatch?.[1] || '' if (!optionSource) { return null } const tokens = optionSource.trim().split(/\s+/) let quoteBackground = '' tokens.forEach((token) => { const [key, rawOptionValue] = token.split('=') const optionValue = String(rawOptionValue || '').trim() if (key?.toLowerCase() === 'bg' && QUOTE_BACKGROUND_OPTIONS.includes(optionValue)) { quoteBackground = optionValue } }) return quoteBackground ? { quoteBackground } : null } /** * 인용 블록을 파싱한다. * @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 = parseQuoteOptionsLine(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) } } /** * 임베드 fenced 블록을 파싱한다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @returns {{ kind: 'embed', startLine: number, endLine: number, url: string }|null} */ const resolveEmbedBlock = (lines, currentLine) => { const standaloneUrl = String(lines[currentLine] || '').trim() if (isStandaloneUrlLine(standaloneUrl) && !isStandaloneImageUrlLine(standaloneUrl)) { return { kind: 'embed', startLine: currentLine, endLine: currentLine, url: standaloneUrl } } const embedStart = findFencedBlockStart(lines, currentLine, ':::embed') if (embedStart === -1) { return null } const embedEnd = findFencedBlockEnd(lines, embedStart) if (embedEnd === -1 || currentLine > embedEnd) { return null } return { kind: 'embed', startLine: embedStart, endLine: embedEnd, url: lines.slice(embedStart + 1, embedEnd).join('\n').trim() } } /** * 콜아웃 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) => { let codeStart = -1 for (let index = currentLine; index >= 0; index -= 1) { if ((lines[index] || '').trim().startsWith('```')) { codeStart = index break } } if (codeStart === -1) { return null } const codeEnd = findCodeFenceEnd(lines, codeStart) if (codeEnd === -1 || currentLine > codeEnd) { return null } const options = parseCodeFenceLine(lines[codeStart]) || { language: '', showLineNumbers: true } return { kind: 'code', startLine: codeStart, endLine: codeEnd, language: options.language, showLineNumbers: options.showLineNumbers } } /** * 커서 줄 기준 활성 블록 컨텍스트를 반환한다. * @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 resolveEmbedBlock(lines, currentLine) }