Files
sori.studio/lib/markdown-block-context.js

294 lines
7.9 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'
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<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)
}
}
/**
* 링크·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)
}