Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 86 additions & 65 deletions src/hooks/util/parseMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// HTML 특수문자 이스케이프 (XSS 방지)
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
Expand All @@ -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(`<strong class="font-semibold">${escapeHtml(g1)}</strong>`);
return `%%BOLD_${index}%%`;
});

// 인라인 코드 처리
const inlineTokens: string[] = [];
const inlineProcessed = tokenized.replace(/`([^`]+)`/g, (_, code) => {
const index = inlineTokens.length;
inlineTokens.push(`<code class="bg-gray200 rounded px-1 text-sm">${escapeHtml(code)}</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(
`<pre class="bg-gray800 text-green400 rounded p-3 text-sm overflow-x-auto my-2"><code>${escapeHtml(code)}</code></pre>`
`<pre class="bg-gray800 text-green400 rounded p-3 text-sm overflow-x-auto my-2"><code>${escapeHtml(
code
)}</code></pre>`
);
return `%%CODEBLOCK_${index}%%`;
});

// 2단계: 인라인 코드도 플레이스홀더로 추출
const inlineCodes: string[] = [];
processed = processed.replace(/`([^`]+)`/g, (_, code) => {
const index = inlineCodes.length;
inlineCodes.push(`<code class="bg-gray200 rounded px-1 text-sm">${escapeHtml(code)}</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(`<ul class="my-2">${listItems.join('')}</ul>`);
listItems = [];
inList = false;
};

for (const line of lines) {
const trimmed = line.trim();

// 리스트 처리
const listMatch = trimmed.match(/^- (.+)$/);
if (listMatch) {
inList = true;
listItems.push(`<li class="ml-4 list-disc">${formatInline(listMatch[1])}</li>`);
continue;
} else {
flushList();
}

// 헤딩 처리
if (/^### (.+)/.test(trimmed)) {
result.push(
`<h3 class="text-base font-semibold mt-3 mb-1">${escapeHtml(trimmed.replace(/^### /, ''))}</h3>`
);
continue;
}
if (/^## (.+)/.test(trimmed)) {
result.push(
`<h2 class="text-lg font-semibold mt-4 mb-1">${escapeHtml(trimmed.replace(/^## /, ''))}</h2>`
);
continue;
}
if (/^# (.+)/.test(trimmed)) {
result.push(
`<h1 class="text-xl font-bold mt-4 mb-2">${escapeHtml(trimmed.replace(/^# /, ''))}</h1>`
);
continue;
}

// 일반 텍스트
result.push(formatInline(trimmed));
}

flushList();

processed = result.join('<br/>');

// 일반 텍스트 구간의 raw HTML 차단
processed = escapeHtml(processed);

// 3단계: 나머지 마크다운 변환 (코드블록 밖에서만 적용됨)
processed = processed
// 헤딩
.replace(
/^### (.+)$/gm,
(_, g1) => `<h3 class="text-base font-semibold mt-3 mb-1">${escapeHtml(g1)}</h3>`
)
.replace(
/^## (.+)$/gm,
(_, g1) => `<h2 class="text-lg font-semibold mt-4 mb-1">${escapeHtml(g1)}</h2>`
)
.replace(
/^# (.+)$/gm,
(_, g1) => `<h1 class="text-xl font-bold mt-4 mb-2">${escapeHtml(g1)}</h1>`
)
// bold
.replace(
/\*\*(.+?)\*\*/g,
(_, g1) => `<strong class="font-semibold">${escapeHtml(g1)}</strong>`
)
// 리스트
.replace(/^\s*- (.+)$/gm, (_, g1) => `<li class="ml-4 list-disc">${escapeHtml(g1)}</li>`)
// 표
.replace(/(\|.+\|\n)((\|[-:| ]+\|\n))((\|.+\|\n?)+)/g, match => {
const rows = match.trim().split('\n');
const headers = rows[0]
.split('|')
.filter(Boolean)
.map(
h =>
`<th class="border border-gray300 px-3 py-1 bg-gray100 font-semibold text-left">${escapeHtml(h.trim())}</th>`
)
.join('');
const bodyRows = rows
.slice(2)
.map(row => {
const cells = row
.split('|')
.filter(Boolean)
.map(c => `<td class="border border-gray300 px-3 py-1">${escapeHtml(c.trim())}</td>`)
.join('');
return `<tr>${cells}</tr>`;
})
.join('');
return `<table class="border-collapse w-full my-3 text-sm"><thead><tr>${headers}</tr></thead><tbody>${bodyRows}</tbody></table>`;
})
// 줄바꿈
.replace(/\n(?!<)/g, '<br/>');

// 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;
Expand All @@ -92,7 +113,7 @@ export default function MarkdownRenderer({
className,
}: {
content: string;
className: string;
className?: string;
}) {
return (
<div className={className} dangerouslySetInnerHTML={{ __html: parseMarkdown(content ?? '') }} />
Expand Down
Loading