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

449 lines
14 KiB
JavaScript

import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js'
import { parseCodeFenceLine } from './markdown-code-block.js'
import { parseToggleOpenerLine } from './markdown-toggle.js'
import { parseMarkdownTableBlock } from './markdown-table.js'
import {
collectFencedLines,
parseBookmarkMeta,
parseMediaMeta,
parseSignupMeta
} from './markdown-fenced-meta.js'
import { parseCalloutOptions } from './markdown-callout.js'
import {
getQuoteLineBody,
isQuoteMarkerLine,
parseQuoteOptions
} from './markdown-quote.js'
/** @type {string} 빈 문단을 보존하는 레거시 마커 */
export const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
/**
* 블록에 원본 마크다운 줄 범위를 붙인다.
* @param {Object} block - 블록
* @param {number} startLine - 시작 줄(0-based)
* @param {number} endLine - 끝 줄(0-based)
* @returns {Object} 블록
*/
export const attachSourceRange = (block, startLine, endLine) => {
block.meta = {
...(block.meta && typeof block.meta === 'object' ? block.meta : {}),
startLine,
endLine: endLine ?? startLine
}
return block
}
/**
* 마크다운 렌더링 블록을 생성한다.
* @param {string} type - 블록 타입
* @param {string|Array<string>} text - 블록 텍스트
* @param {number|null} level - 제목 레벨
* @param {string} id - 블록 ID
* @param {Object} options - 추가 블록 옵션
* @returns {Object} 블록
*/
export const createMarkdownBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
id,
type,
text,
level,
url: options.url || '',
alt: options.alt || '',
caption: options.caption || '',
useAlt: options.useAlt === true,
title: options.title || '',
variant: options.variant || '',
ordered: options.ordered || false,
listNumbers: Array.isArray(options.listNumbers) ? options.listNumbers : [],
width: options.width || 'regular',
pixelWidth: Math.round(Number(options.pixelWidth) || 0) || null,
images: options.images || [],
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
calloutEmojiEnabled: options.calloutEmojiEnabled === true,
calloutEmoji: options.calloutEmoji || '💡',
calloutBackground: options.calloutBackground || 'blue',
quoteBackground: options.quoteBackground || 'gray',
defaultOpen: options.defaultOpen === true,
codeLanguage: options.codeLanguage || '',
codeShowLineNumbers: options.codeShowLineNumbers === true
})
/**
* 이미지 마크다운 행을 이미지 데이터로 변환한다.
* @param {string} line - 마크다운 행
* @returns {Object|null} 이미지 데이터
*/
const parseImageLine = (line) => parseImageMarkdownLine(line)
/**
* 불릿 목록 마커 줄인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} 불릿 목록 여부
*/
const isBulletListMarkerLine = (line) => {
const trimmed = String(line ?? '').trim()
return trimmed === '-' || /^-\s/.test(trimmed)
}
/**
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} 블록 시작 여부
*/
export const isMarkdownBlockStart = (line) => {
const trimmedLine = line.trim()
return trimmedLine === BLANK_PARAGRAPH_MARKER ||
trimmedLine === '>>>' ||
trimmedLine === ':::bookmark' ||
trimmedLine === ':::signup' ||
trimmedLine === ':::gallery' ||
trimmedLine === ':::video' ||
trimmedLine === ':::audio' ||
trimmedLine === ':::file' ||
trimmedLine === ':::link' ||
trimmedLine === ':::iframe' ||
trimmedLine === ':::html' ||
trimmedLine.startsWith(':::callout') ||
trimmedLine.startsWith(':::toggle') ||
trimmedLine.startsWith('```') ||
trimmedLine === '---' ||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
trimmedLine.startsWith('> ') ||
/^- /.test(trimmedLine) ||
/^\d+\.\s+/.test(trimmedLine) ||
Boolean(parseImageLine(trimmedLine))
}
/**
* 문단 행에서 hard break 표식(레거시)을 제거한다.
* @param {string} line - 마크다운 행
* @returns {string} 정리된 문단 행
*/
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
/**
* 단독 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} markdown - 마크다운 문자열
* @returns {Array<Object>} 블록 목록
*/
export const parseMarkdownBlocks = (markdown) => {
const lines = markdown.split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index]
const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
const startLine = index
blocks.push(attachSourceRange(createMarkdownBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }), startLine, startLine))
index += 1
continue
}
if (!trimmedLine) {
const startLine = index
blocks.push(attachSourceRange(createMarkdownBlock('spacer', '', null, `block-${blocks.length}`), startLine, startLine))
index += 1
continue
}
if (trimmedLine === '>>>') {
const startLine = index
const contentLines = []
index += 1
while (index < lines.length && lines[index].trim() !== '<<<') {
contentLines.push(lines[index])
index += 1
}
blocks.push(attachSourceRange(
createMarkdownBlock('quote', contentLines.join('\n'), null, `block-${blocks.length}`, { variant: 'alt' }),
startLine,
index - 1
))
index += 1
continue
}
if (trimmedLine === ':::bookmark') {
const startLine = index
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
if (bookmarkMeta.url) {
blocks.push(attachSourceRange(
createMarkdownBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }),
startLine,
endLine
))
}
index = nextIndex
continue
}
if (trimmedLine === ':::signup') {
const startLine = index
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const signupMeta = parseSignupMeta(contentLines.join('\n'))
blocks.push(attachSourceRange(
createMarkdownBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }),
startLine,
endLine
))
index = nextIndex
continue
}
if (trimmedLine === ':::gallery') {
const startLine = index
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const images = []
contentLines.forEach((contentLine) => {
const image = parseImageLine(contentLine)
if (image) {
images.push(image)
}
})
blocks.push(attachSourceRange(createMarkdownBlock('gallery', '', null, `block-${blocks.length}`, {
images
}), startLine, endLine))
index = nextIndex
continue
}
if ([':::video', ':::audio', ':::file'].includes(trimmedLine)) {
const startLine = index
const blockType = trimmedLine.replace(':::', '')
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const mediaMeta = parseMediaMeta(contentLines.join('\n'))
blocks.push(attachSourceRange(
createMarkdownBlock(blockType, '', null, `block-${blocks.length}`, { meta: mediaMeta }),
startLine,
endLine
))
index = nextIndex
continue
}
if (trimmedLine.startsWith(':::callout')) {
const startLine = index
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(attachSourceRange(
createMarkdownBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)),
startLine,
endLine
))
index = nextIndex
continue
}
if (trimmedLine.startsWith(':::toggle')) {
const startLine = index
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const toggleOptions = parseToggleOpenerLine(trimmedLine)
blocks.push(attachSourceRange(
createMarkdownBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, toggleOptions),
startLine,
endLine
))
index = nextIndex
continue
}
if ([':::link', ':::iframe', ':::html'].includes(trimmedLine)) {
const startLine = index
const blockType = trimmedLine.replace(':::', '')
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(attachSourceRange(
createMarkdownBlock(blockType, '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
startLine,
endLine
))
index = nextIndex
continue
}
if (isStandaloneImageUrlLine(trimmedLine)) {
const startLine = index
blocks.push(attachSourceRange(createMarkdownBlock('image', '', null, `block-${blocks.length}`, {
url: trimmedLine,
width: 'regular',
pixelWidth: null
}), startLine, startLine))
index += 1
continue
}
const image = parseImageLine(trimmedLine)
if (image) {
const startLine = index
blocks.push(attachSourceRange(createMarkdownBlock('image', '', null, `block-${blocks.length}`, image), startLine, startLine))
index += 1
continue
}
if (trimmedLine.startsWith('```')) {
const startLine = index
const fenceOptions = parseCodeFenceLine(trimmedLine) || { language: '', showLineNumbers: false }
const codeLines = []
index += 1
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index])
index += 1
}
if (index >= lines.length) {
index = startLine
} else {
blocks.push(attachSourceRange(
createMarkdownBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, {
codeLanguage: fenceOptions.language,
codeShowLineNumbers: fenceOptions.showLineNumbers
}),
startLine,
index
))
index += 1
continue
}
}
if (trimmedLine === '---') {
const startLine = index
blocks.push(attachSourceRange(
createMarkdownBlock('divider', '', null, `block-${blocks.length}`),
startLine,
startLine
))
index += 1
continue
}
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
const startLine = index
blocks.push(attachSourceRange(
createMarkdownBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`),
startLine,
startLine
))
index += 1
continue
}
const tableBlock = parseMarkdownTableBlock(lines, index)
if (tableBlock) {
const startLine = index
blocks.push(attachSourceRange(
createMarkdownBlock('table', '', null, `block-${blocks.length}`, {
meta: {
headers: tableBlock.headers,
alignments: tableBlock.alignments,
rows: tableBlock.rows
}
}),
startLine,
tableBlock.endLine
))
index = tableBlock.nextIndex
continue
}
if (isQuoteMarkerLine(line)) {
const startLine = index
const rawQuoteLines = []
while (index < lines.length && isQuoteMarkerLine(lines[index])) {
rawQuoteLines.push(getQuoteLineBody(lines[index]))
index += 1
}
const quoteOptions = parseQuoteOptions(rawQuoteLines[0])
const quoteLines = quoteOptions ? rawQuoteLines.slice(1) : rawQuoteLines
const contentStartLine = startLine + (quoteOptions ? 1 : 0)
blocks.push(attachSourceRange(
createMarkdownBlock('quote', (quoteLines.length ? quoteLines : ['']).join('\n'), null, `block-${blocks.length}`, {
...(quoteOptions || {}),
meta: {
quoteContentStartLine: contentStartLine
}
}),
startLine,
index - 1
))
continue
}
if (isBulletListMarkerLine(line)) {
const startLine = index
const items = []
while (index < lines.length && isBulletListMarkerLine(lines[index])) {
items.push(lines[index].trim().replace(/^-\s?/, ''))
index += 1
}
blocks.push(attachSourceRange(createMarkdownBlock('list', items, null, `block-${blocks.length}`), startLine, index - 1))
continue
}
if (/^\d+\.\s*/.test(trimmedLine)) {
const startLine = index
const items = []
const listNumbers = []
while (index < lines.length && /^\d+\.\s*/.test(lines[index].trim())) {
const match = lines[index].trim().match(/^(\d+)\.\s*(.*)$/)
listNumbers.push(Number(match[1]))
items.push(String(match[2] ?? '').trim())
index += 1
}
blocks.push(attachSourceRange(
createMarkdownBlock('list', items, null, `block-${blocks.length}`, { ordered: true, listNumbers }),
startLine,
index - 1
))
continue
}
blocks.push(attachSourceRange(
createMarkdownBlock('paragraph', cleanParagraphLine(line), null, `block-${blocks.length}`),
index,
index
))
index += 1
}
return blocks
}