/** * 마크다운 + 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: [] }); } }