/**
* 마크다운 + KaTeX 수식 통합 렌더링 (PR-7).
*
* 적용:
* - $...$ inline math
* - $$...$$ block math
* - 일반 markdown (**굵게**, 리스트, 코드 등) — DocumentViewer 와 동일 출력 호환
*
* 사용:
* import { renderMathMarkdown } from '$lib/utils/mathMarkdown';
*
{@html renderMathMarkdown(text)}
*
* 주의:
* - 입력값은 그대로 저장. 변환은 표시 시점에만.
* - 수식 렌더 실패 시 fallback (KaTeX throwOnError: false → 잘못된 수식은 빨간 텍스트로 표시).
* - DOMPurify 로 XSS 차단. KaTeX HTML span/class/style 만 허용.
* - 통합뷰 카드 같은 곳에서는 raw 텍스트 보존이 더 자연스러움 → 호출 안 함.
*/
import DOMPurify from 'dompurify';
import { Marked } from 'marked';
// @ts-ignore — marked-katex-extension 타입 정의 누락 시 무시
import markedKatex from 'marked-katex-extension';
import 'katex/dist/katex.min.css';
// 별도 인스턴스 — 글로벌 marked 사용처(DocumentViewer 등) 영향 없음.
const mathMarked = new Marked();
mathMarked.use({ mangle: false, headerIds: false } as any);
mathMarked.use(
markedKatex({
// 잘못된 수식이면 빨간색으로 그대로 출력 (페이지 깨지지 않음)
throwOnError: false,
// 단일 $ inline / 이중 $$ block 모두 지원
nonStandard: false,
// KaTeX HTML 출력만 (MathML 비활성). DOMPurify HTML profile 만으로 충분.
output: 'html',
} as any),
);
const SANITIZE_OPTS = {
USE_PROFILES: { html: true },
// KaTeX 가 inline style + aria-hidden 사용. class 는 USE_PROFILES.html 기본 허용.
ADD_ATTR: ['style', 'aria-hidden'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link', 'meta'],
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover', 'onfocus'],
ALLOW_UNKNOWN_PROTOCOLS: false,
} as const;
/**
* 텍스트 전체가 단일 fenced code block 으로 감싸진 경우만 unwrap.
*
* 두 케이스 처리:
* (1) terminated: 시작 + 끝 모두 ``` — 백틱 그룹 정확히 2번
* (2) unterminated: 시작 ``` 만 있고 닫음 누락 — 백틱 그룹 정확히 1번
*
* 보존 케이스 (그대로):
* 설명 문장
* ```python
* print("hi")
* ```
* 추가 설명
* → 백틱 그룹이 2 이상이지만 ``` 가 본문 중간에 있어 wrap 패턴 매칭 안 됨.
*
* 안전 조건:
* - terminated: inner 에 ``` 가 또 있으면 보존 (사용자가 진짜 코드블록 의도).
* - unterminated: 본문에 ``` 가 그 시작 한 번만 있어야 함 (백틱 그룹 = 1).
*
* AI 응답이 마크다운 자체를 ``` 으로 감싸서 오는 패턴을 자동 처리.
*/
function stripOuterFence(text: string): string {
const trimmed = text.trim();
// (1) terminated wrap: ```...\n본문\n``` 끝.
const term = trimmed.match(/^```[A-Za-z0-9_-]*[ \t]*\n([\s\S]*?)\n```$/);
if (term) {
const inner = term[1];
if (!inner.includes('```')) return inner;
return text;
}
// (2) unterminated: 시작 ``` 만, 본문 중에 ``` 가 더 없음.
const backtickGroups = (trimmed.match(/```/g) || []).length;
if (backtickGroups === 1) {
const unterm = trimmed.match(/^```[A-Za-z0-9_-]*[ \t]*\n([\s\S]*)$/);
if (unterm) return unterm[1];
}
return text;
}
export function renderMathMarkdown(text: string | null | undefined): string {
if (!text) return '';
try {
const normalized = stripOuterFence(text);
const html = mathMarked.parse(normalized) as string;
return DOMPurify.sanitize(html, SANITIZE_OPTS);
} catch {
return DOMPurify.sanitize(text, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
}
}
/**
* Inline 전용 렌더 — 같은 block 태그로 감싸지 않는다.
* 보기 1~4 처럼 줄 한 줄짜리 짧은 텍스트에 사용 (button 안에
가 들어가면 레이아웃 깨짐).
*/
export function renderMathMarkdownInline(text: string | null | undefined): string {
if (!text) return '';
try {
const html = mathMarked.parseInline(text) as string;
return DOMPurify.sanitize(html, SANITIZE_OPTS);
} catch {
return DOMPurify.sanitize(text, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
}
}