diff --git a/src/hooks/util/parseMarkdown.tsx b/src/hooks/util/parseMarkdown.tsx index 07472ce..97d2504 100644 --- a/src/hooks/util/parseMarkdown.tsx +++ b/src/hooks/util/parseMarkdown.tsx @@ -1,4 +1,3 @@ -// HTML 특수문자 이스케이프 (XSS 방지) function escapeHtml(text: string): string { return text .replace(/&/g, '&') @@ -8,80 +7,102 @@ function escapeHtml(text: string): string { .replace(/'/g, '''); } +const formatInline = (raw: string) => { + if (!raw) return ''; + + // Bold 처리 + const boldTokens: string[] = []; + const tokenized = raw.replace(/\*\*(.+?)\*\*/g, (_, g1) => { + const index = boldTokens.length; + boldTokens.push(`${escapeHtml(g1)}`); + return `%%BOLD_${index}%%`; + }); + + // 인라인 코드 처리 + const inlineTokens: string[] = []; + const inlineProcessed = tokenized.replace(/`([^`]+)`/g, (_, code) => { + const index = inlineTokens.length; + inlineTokens.push(`${escapeHtml(code)}`); + return `%%INLINE_${index}%%`; + }); + + // placeholder 복원 + let restored = inlineProcessed.replace(/%%BOLD_(\d+)%%/g, (_, i) => boldTokens[Number(i)]); + restored = restored.replace(/%%INLINE_(\d+)%%/g, (_, i) => inlineTokens[Number(i)]); + + return restored; +}; + function parseMarkdown(text: string): string { if (!text || typeof text !== 'string') return ''; - // 1단계: 코드블록을 먼저 플레이스홀더로 추출 (내부 내용이 다른 regex에 오염되지 않도록) + + // 코드블록 추출 const codeBlocks: string[] = []; let processed = text.replace(/```[\w]*\n([\s\S]*?)```/g, (_, code) => { const index = codeBlocks.length; codeBlocks.push( - `
${escapeHtml(code)}
` + `
${escapeHtml(
+        code
+      )}
` ); return `%%CODEBLOCK_${index}%%`; }); - // 2단계: 인라인 코드도 플레이스홀더로 추출 - const inlineCodes: string[] = []; - processed = processed.replace(/`([^`]+)`/g, (_, code) => { - const index = inlineCodes.length; - inlineCodes.push(`${escapeHtml(code)}`); - return `%%INLINECODE_${index}%%`; - }); + // 라인 단위로 처리 + const lines = processed.split('\n'); + const result: string[] = []; + + let inList = false; + let listItems: string[] = []; + const flushList = () => { + if (!inList) return; + result.push(``); + listItems = []; + inList = false; + }; + + for (const line of lines) { + const trimmed = line.trim(); + + // 리스트 처리 + const listMatch = trimmed.match(/^- (.+)$/); + if (listMatch) { + inList = true; + listItems.push(`
  • ${formatInline(listMatch[1])}
  • `); + continue; + } else { + flushList(); + } + + // 헤딩 처리 + if (/^### (.+)/.test(trimmed)) { + result.push( + `

    ${escapeHtml(trimmed.replace(/^### /, ''))}

    ` + ); + continue; + } + if (/^## (.+)/.test(trimmed)) { + result.push( + `

    ${escapeHtml(trimmed.replace(/^## /, ''))}

    ` + ); + continue; + } + if (/^# (.+)/.test(trimmed)) { + result.push( + `

    ${escapeHtml(trimmed.replace(/^# /, ''))}

    ` + ); + continue; + } + + // 일반 텍스트 + result.push(formatInline(trimmed)); + } + + flushList(); + + processed = result.join('
    '); - // 일반 텍스트 구간의 raw HTML 차단 - processed = escapeHtml(processed); - - // 3단계: 나머지 마크다운 변환 (코드블록 밖에서만 적용됨) - processed = processed - // 헤딩 - .replace( - /^### (.+)$/gm, - (_, g1) => `

    ${escapeHtml(g1)}

    ` - ) - .replace( - /^## (.+)$/gm, - (_, g1) => `

    ${escapeHtml(g1)}

    ` - ) - .replace( - /^# (.+)$/gm, - (_, g1) => `

    ${escapeHtml(g1)}

    ` - ) - // bold - .replace( - /\*\*(.+?)\*\*/g, - (_, g1) => `${escapeHtml(g1)}` - ) - // 리스트 - .replace(/^\s*- (.+)$/gm, (_, g1) => `
  • ${escapeHtml(g1)}
  • `) - // 표 - .replace(/(\|.+\|\n)((\|[-:| ]+\|\n))((\|.+\|\n?)+)/g, match => { - const rows = match.trim().split('\n'); - const headers = rows[0] - .split('|') - .filter(Boolean) - .map( - h => - `${escapeHtml(h.trim())}` - ) - .join(''); - const bodyRows = rows - .slice(2) - .map(row => { - const cells = row - .split('|') - .filter(Boolean) - .map(c => `${escapeHtml(c.trim())}`) - .join(''); - return `${cells}`; - }) - .join(''); - return `${headers}${bodyRows}
    `; - }) - // 줄바꿈 - .replace(/\n(?!<)/g, '
    '); - - // 4단계: 플레이스홀더를 실제 HTML로 복원 - processed = processed.replace(/%%INLINECODE_(\d+)%%/g, (_, i) => inlineCodes[Number(i)]); + // 코드블록 placeholder 복원 processed = processed.replace(/%%CODEBLOCK_(\d+)%%/g, (_, i) => codeBlocks[Number(i)]); return processed; @@ -92,7 +113,7 @@ export default function MarkdownRenderer({ className, }: { content: string; - className: string; + className?: string; }) { return (