399 lines
10 KiB
JavaScript
399 lines
10 KiB
JavaScript
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'
|
|
|
|
/**
|
|
* 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' && 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<Object> }|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)
|
|
}
|