/** @type {RegExp} 인라인 마크다운 패턴 */ const INLINE_MARKDOWN_RE = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g /** * HTML 특수문자 이스케이프 * @param {string} value - 원본 문자열 * @returns {string} 이스케이프된 문자열 */ export const escapeHtml = (value) => String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') /** * 인라인 마크다운을 표시 세그먼트로 변환한다. * @param {string} value - 원본 문자열 * @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트 */ 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) while (match) { if (match.index > lastIndex) { segments.push({ type: 'text', text: source.slice(lastIndex, match.index) }) } if (match[2] && match[3]) { segments.push({ type: 'link', text: match[2], href: match[3] }) } else if (match[4]) { segments.push({ type: 'strong', text: match[4] }) } else if (match[5]) { segments.push({ type: 'code', text: match[5] }) } else if (match[6]) { segments.push({ type: 'em', text: match[6] }) } lastIndex = INLINE_MARKDOWN_RE.lastIndex match = INLINE_MARKDOWN_RE.exec(source) } if (lastIndex < source.length) { segments.push({ type: 'text', text: source.slice(lastIndex) }) } return segments.length ? segments : [{ type: 'text', text: source }] } /** * 인라인 세그먼트를 HTML 문자열로 변환한다. * @param {Array<{ type: string, text: string, href?: string }>} segments - 세그먼트 * @returns {string} HTML */ export const segmentsToInlineHtml = (segments) => segments.map((segment) => { if (segment.type === 'strong') { return `${escapeHtml(segment.text)}` } if (segment.type === 'em') { return `${escapeHtml(segment.text)}` } if (segment.type === 'code') { return `${escapeHtml(segment.text)}` } if (segment.type === 'link') { return `${escapeHtml(segment.text)}` } return escapeHtml(segment.text) }).join('') /** * 인라인 마크다운(단일 줄)을 HTML로 변환한다. * @param {string} value - 마크다운 문자열 * @returns {string} HTML */ export const markdownInlineToHtml = (value) => segmentsToInlineHtml(parseInlineSegments(value)) /** * 여러 줄 인라인 마크다운을 HTML로 변환한다. * @param {string} value - 줄바꿈 포함 마크다운 * @returns {string} HTML */ export const markdownMultilineInlineToHtml = (value) => String(value || '') .split('\n') .map((line) => markdownInlineToHtml(line)) .join('
') /** * 인라인 HTML 노드를 마크다운 문자열로 변환한다. * @param {Node} node - HTML 노드 * @returns {string} 마크다운 문자열 */ export const convertHtmlInlineNodeToMarkdown = (node) => { if (node.nodeType === Node.TEXT_NODE) { return node.textContent || '' } if (node.nodeType !== Node.ELEMENT_NODE) { return '' } const element = /** @type {HTMLElement} */ (node) const tagName = element.tagName.toLowerCase() const childText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('') if (tagName === 'strong' || tagName === 'b') { return `**${childText}**` } if (tagName === 'em' || tagName === 'i') { return `*${childText}*` } if (tagName === 'code') { return `\`${childText}\`` } if (tagName === 'a') { const href = element.getAttribute('href') return href ? `[${childText || href}](${href})` : childText } if (tagName === 'img') { const src = element.getAttribute('src') const alt = element.getAttribute('alt') || '' return src ? `![${alt}](${src})` : '' } if (tagName === 'br') { return '\n' } if (tagName === 'div' || tagName === 'p' || tagName === 'span') { return childText } return childText } /** * Range에 포함된 HTML 조각을 반환한다. * @param {Range} range - DOM Range * @returns {string} HTML */ export const getRangeInnerHtml = (range) => { if (!range) { return '' } const fragment = range.cloneContents() const container = document.createElement('div') container.appendChild(fragment) return container.innerHTML } /** * contenteditable 내부 HTML을 인라인 마크다운으로 변환한다. * @param {string} html - innerHTML * @returns {string} 마크다운 */ export const convertEditableHtmlToMarkdown = (html) => { if (!html?.trim()) { return '' } const document = new DOMParser().parseFromString(`${html}`, 'text/html') const parts = [] document.body.childNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE && /** @type {HTMLElement} */ (node).tagName.toLowerCase() === 'br') { parts.push('\n') return } const converted = convertHtmlInlineNodeToMarkdown(node) if (converted) { parts.push(converted) } }) return parts.join('').replace(/\u00a0/g, ' ').trimEnd() } /** * 문단 블록 텍스트를 소스 줄 배열로 변환한다. * @param {string} text - 문단 텍스트 * @returns {string[]} 마크다운 줄 */ /** * HTML 블록 노드를 마크다운 문자열로 변환한다. * @param {Node} node - HTML 노드 * @param {number} listIndex - 순서 목록 번호 * @returns {string} 마크다운 문자열 */ export const convertHtmlBlockNodeToMarkdown = (node, listIndex = 1) => { if (node.nodeType === Node.TEXT_NODE) { return (node.textContent || '').trim() } if (node.nodeType !== Node.ELEMENT_NODE) { return '' } const element = /** @type {HTMLElement} */ (node) const tagName = element.tagName.toLowerCase() const inlineText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim() if (/^h[1-6]$/.test(tagName)) { return `${'#'.repeat(Number(tagName.slice(1)))} ${inlineText}` } if (tagName === 'p') { return inlineText } if (tagName === 'blockquote') { return inlineText.split('\n').map((line) => `> ${line}`).join('\n') } if (tagName === 'pre') { return `\`\`\`\n${element.textContent?.trim() || ''}\n\`\`\`` } if (tagName === 'li') { return `${listIndex}. ${inlineText}` } if (tagName === 'ul' || tagName === 'ol') { return Array.from(element.children) .filter((child) => child.tagName.toLowerCase() === 'li') .map((child, index) => { const marker = tagName === 'ol' ? `${index + 1}.` : '-' const text = Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim() return `${marker} ${text}` }) .join('\n') } if (tagName === 'div' || tagName === 'section' || tagName === 'article') { const childBlocks = Array.from(element.childNodes) .map(convertHtmlBlockNodeToMarkdown) .filter(Boolean) return childBlocks.length ? childBlocks.join('\n\n') : inlineText } return inlineText } /** * 클립보드 HTML을 마크다운 문서 조각으로 변환한다. * @param {string} html - HTML 문자열 * @returns {string} 마크다운 문자열 */ export const convertHtmlToMarkdown = (html) => { const document = new DOMParser().parseFromString(html, 'text/html') return Array.from(document.body.childNodes) .map(convertHtmlBlockNodeToMarkdown) .filter(Boolean) .join('\n\n') .replace(/\n{3,}/g, '\n\n') .trim() } /** * 문단 블록 텍스트를 소스 줄 배열로 변환한다. * @param {string} text - 문단 텍스트 * @returns {string[]} 마크다운 줄 */ export const paragraphTextToSourceLines = (text) => { const parts = String(text || '').split('\n') if (!parts.length) { return [''] } if (parts.length === 1) { return [parts[0]] } return parts.map((part, index) => (index < parts.length - 1 ? `${part} ` : part)) }