/** * fenced 블록 시작 줄 인덱스를 찾는다. * @param {string[]} lines - 본문 줄 목록 * @param {number} currentLine - 현재 줄 * @param {string} opener - 시작 토큰 * @returns {number} 시작 줄 또는 -1 */ export 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 */ export 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 */ export const findFencedBlockEnd = (lines, startLine) => { for (let index = startLine + 1; index < lines.length; index += 1) { if ((lines[index] || '').trim() === ':::') { return index } } return -1 } /** * 닫힘 표식까지의 행 목록을 반환한다. * @param {Array} lines - 전체 마크다운 행 * @param {number} startIndex - 본문 시작 인덱스 * @returns {{contentLines: Array, endLine: number, nextIndex: number}} 블록 본문과 다음 인덱스 */ export const collectFencedLines = (lines, startIndex) => { const contentLines = [] let index = startIndex while (index < lines.length && lines[index].trim() !== ':::') { contentLines.push(lines[index]) index += 1 } return { contentLines, endLine: index < lines.length ? index : Math.max(lines.length - 1, startIndex - 1, 0), nextIndex: index < lines.length ? index + 1 : lines.length } } /** * 북마크 fenced 블록 본문에서 URL·제목·설명·썸네일을 파싱한다. * @param {string} raw - fenced 내부 텍스트 * @returns {{url: string, title: string, description: string, thumbnail: string}} 북마크 메타 */ export const parseBookmarkMeta = (raw) => { const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean) const meta = { url: '', title: '', description: '', thumbnail: '' } for (const line of lines) { const kv = line.match(/^(\w+)=(.*)$/) if (kv) { const key = kv[1].toLowerCase() const value = kv[2].trim() if (key === 'url') { meta.url = value } else if (key === 'title') { meta.title = value } else if (key === 'description' || key === 'desc') { meta.description = value } else if (key === 'thumbnail' || key === 'image') { meta.thumbnail = value } continue } if (!meta.url && /^https?:\/\//i.test(line)) { meta.url = line continue } if (meta.url && !meta.title) { meta.title = line continue } if (meta.url && meta.title && !meta.description) { meta.description = line } } return meta } /** * 회원가입(CTA) fenced 블록 본문에서 표시 문구를 파싱한다. * @param {string} raw - fenced 내부 텍스트 * @returns {{title: string, description: string, button: string, placeholder: string}} CTA 메타 */ export const parseSignupMeta = (raw) => { const meta = { title: '뉴스레터에 가입하세요', description: '새 글이 올라오면 받아보실 수 있어요.', button: '구독하기', placeholder: 'you@example.com' } const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean) for (const line of lines) { const kv = line.match(/^(\w+)=(.*)$/) if (!kv) { continue } const key = kv[1].toLowerCase() const value = kv[2].trim() if (key === 'title') { meta.title = value } else if (key === 'description' || key === 'desc') { meta.description = value } else if (key === 'button') { meta.button = value } else if (key === 'placeholder') { meta.placeholder = value } } return meta } /** * 미디어 fenced 블록 본문에서 URL과 표시 메타를 파싱한다. * @param {string} raw - fenced 내부 텍스트 * @returns {{url: string, title: string, description: string, poster: string, caption: string, fileName: string, size: string}} 미디어 메타 */ export const parseMediaMeta = (raw) => { const meta = { url: '', title: '', description: '', poster: '', caption: '', fileName: '', size: '' } const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean) for (const line of lines) { const kv = line.match(/^(\w+)=(.*)$/) if (kv) { const key = kv[1].toLowerCase() const value = kv[2].trim() if (key === 'url' || key === 'src') { meta.url = value } else if (key === 'title') { meta.title = value } else if (key === 'description' || key === 'desc') { meta.description = value } else if (key === 'poster' || key === 'thumbnail') { meta.poster = value } else if (key === 'caption') { meta.caption = value } else if (key === 'name' || key === 'filename' || key === 'file') { meta.fileName = value } else if (key === 'size') { meta.size = value } continue } if (!meta.url && (/^https?:\/\//i.test(line) || line.startsWith('/'))) { meta.url = line continue } if (meta.url && !meta.title) { meta.title = line continue } if (meta.url && meta.title && !meta.description) { meta.description = line } } return meta }