import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js' import { CALLOUT_BACKGROUND_OPTIONS } from './markdown-callout.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} 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' && CALLOUT_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 || 'pink', 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() } } /** * 커서 줄 기준 활성 블록 컨텍스트를 반환한다. * @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 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 } return resolveEmbedBlock(lines, currentLine) }