콜아웃 인라인 링크 렌더링 개선

This commit is contained in:
2026-07-02 15:24:09 +09:00
parent 3dd30d7f76
commit 00cb4a293d
12 changed files with 182 additions and 50 deletions

View File

@@ -1,8 +1,11 @@
/** @type {string[]} 인라인 색상 프리셋 */
export const INLINE_COLOR_NAMES = ['gray', 'blue', 'green', 'yellow', 'red', 'purple']
/** @type {RegExp} 인라인 색상 시작 패턴 */
const INLINE_COLOR_START_RE = /::(gray|blue|green|yellow|red|purple)\[/g
/** @type {RegExp} 인라인 마크다운 패턴 */
const INLINE_MARKDOWN_RE = /(::(gray|blue|green|yellow|red|purple)\[((?:\\\]|[^\]\n])+)\]|\$([^$\n]+?)\$|\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
const INLINE_MARKDOWN_RE = /(\$([^$\n]+?)\$|\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
/**
* 지원하는 인라인 색상인지 확인한다.
@@ -149,6 +152,77 @@ export const formatInlineColorMarkdown = (color, value) => {
return `::${safeColor}[${text}]`
}
/**
* 색상 토큰 본문을 대괄호 중첩 기준으로 읽는다.
* @param {string} source - 원본 문자열
* @param {number} startIndex - 색상 토큰 시작 위치
* @param {string} color - 색상 이름
* @returns {{ color: string, text: string, nextIndex: number }|null} 색상 토큰
*/
const readInlineColorToken = (source, startIndex, color) => {
const prefix = `::${color}[`
const contentStartIndex = startIndex + prefix.length
let depth = 1
let escaped = false
for (let index = contentStartIndex; index < source.length; index += 1) {
const character = source[index]
if (escaped) {
escaped = false
continue
}
if (character === '\\') {
escaped = true
continue
}
if (character === '[') {
depth += 1
continue
}
if (character !== ']') {
continue
}
depth -= 1
if (depth === 0) {
return {
color,
text: source.slice(contentStartIndex, index).replaceAll('\\]', ']'),
nextIndex: index + 1
}
}
}
return null
}
/**
* 다음 인라인 색상 토큰 후보를 찾는다.
* @param {string} source - 원본 문자열
* @param {number} startIndex - 검색 시작 위치
* @returns {RegExpExecArray|null} 정규식 매치
*/
const findNextInlineColorStart = (source, startIndex) => {
INLINE_COLOR_START_RE.lastIndex = startIndex
return INLINE_COLOR_START_RE.exec(source)
}
/**
* 다음 일반 인라인 토큰 후보를 찾는다.
* @param {string} source - 원본 문자열
* @param {number} startIndex - 검색 시작 위치
* @returns {RegExpExecArray|null} 정규식 매치
*/
const findNextInlineMarkdownToken = (source, startIndex) => {
INLINE_MARKDOWN_RE.lastIndex = startIndex
return INLINE_MARKDOWN_RE.exec(source)
}
/**
* 인라인 마크다운을 표시 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
@@ -158,50 +232,77 @@ export const parseInlineSegments = (value) => {
const source = String(value || '')
const segments = []
let lastIndex = 0
INLINE_MARKDOWN_RE.lastIndex = 0
let match = INLINE_MARKDOWN_RE.exec(source)
let searchIndex = 0
let colorMatch = findNextInlineColorStart(source, searchIndex)
let match = findNextInlineMarkdownToken(source, searchIndex)
while (match) {
if (match.index > lastIndex) {
while (colorMatch || match) {
const useColorMatch = colorMatch && (!match || colorMatch.index <= match.index)
const activeMatch = useColorMatch ? colorMatch : match
if (activeMatch.index > lastIndex) {
segments.push({
type: 'text',
text: source.slice(lastIndex, match.index)
text: source.slice(lastIndex, activeMatch.index)
})
}
if (match[2] && match[3]) {
segments.push({
type: 'color',
color: match[2],
text: match[3].replaceAll('\\]', ']')
})
} else if (match[4]) {
segments.push(...parseObsidianMathSegments(match[4]))
} else if (match[5] && match[6]) {
if (useColorMatch) {
const colorToken = readInlineColorToken(source, colorMatch.index, colorMatch[1])
if (!colorToken) {
segments.push({
type: 'text',
text: source.slice(colorMatch.index, colorMatch.index + colorMatch[0].length)
})
lastIndex = colorMatch.index + colorMatch[0].length
searchIndex = lastIndex
} else {
segments.push({
type: 'color',
color: colorToken.color,
text: colorToken.text
})
lastIndex = colorToken.nextIndex
searchIndex = colorToken.nextIndex
}
} else if (match[2]) {
segments.push(...parseObsidianMathSegments(match[2]))
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
} else if (match[3] && match[4]) {
segments.push({
type: 'link',
text: match[5],
href: match[6]
text: match[3],
href: match[4]
})
} else if (match[7]) {
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
} else if (match[5]) {
segments.push({
type: 'strong',
text: match[7]
text: match[5]
})
} else if (match[8]) {
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
} else if (match[6]) {
segments.push({
type: 'code',
text: match[8]
text: match[6]
})
} else if (match[9]) {
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
} else if (match[7]) {
segments.push({
type: 'em',
text: match[9]
text: match[7]
})
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
}
lastIndex = INLINE_MARKDOWN_RE.lastIndex
match = INLINE_MARKDOWN_RE.exec(source)
colorMatch = findNextInlineColorStart(source, searchIndex)
match = findNextInlineMarkdownToken(source, searchIndex)
}
if (lastIndex < source.length) {
@@ -221,19 +322,19 @@ export const parseInlineSegments = (value) => {
*/
export const segmentsToInlineHtml = (segments) => segments.map((segment) => {
if (segment.type === 'strong') {
return `<strong>${escapeHtml(segment.text)}</strong>`
return `<strong>${segmentsToInlineHtml(parseInlineSegments(segment.text))}</strong>`
}
if (segment.type === 'em') {
return `<em>${escapeHtml(segment.text)}</em>`
return `<em>${segmentsToInlineHtml(parseInlineSegments(segment.text))}</em>`
}
if (segment.type === 'subscript') {
return `<sub>${escapeHtml(segment.text)}</sub>`
return `<sub>${segmentsToInlineHtml(parseInlineSegments(segment.text))}</sub>`
}
if (segment.type === 'superscript') {
return `<sup>${escapeHtml(segment.text)}</sup>`
return `<sup>${segmentsToInlineHtml(parseInlineSegments(segment.text))}</sup>`
}
if (segment.type === 'code') {