373dd059b7
AI 응답이 마크다운 자체를 \`\`\` 으로 감싸서 오는 패턴 (시작만 있고 닫음 누락 포함) 때문에 explanation/AI 해설 영역이 raw 코드블록으로 보이는 회귀. - frontend/lib/utils/mathMarkdown.ts: stripOuterFence helper. - terminated wrap 처리 (inner 에 \`\`\` 추가 있으면 보존) - unterminated 처리 (백틱 그룹 == 1 인 경우만 안전하게 unwrap) - 본문 중간 정상 코드블록은 보존 - scripts/strip_outer_fences.py: dry-run + --apply 양 모드. - 5개 필드 (question_text, choice_1~4, explanation, ai_explanation, content) 검사. - 운영 결과 explanation 34건 unwrap 적용 완료, recount 0 검증. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
/**
|
|
* 마크다운 + KaTeX 수식 통합 렌더링 (PR-7).
|
|
*
|
|
* 적용:
|
|
* - $...$ inline math
|
|
* - $$...$$ block math
|
|
* - 일반 markdown (**굵게**, 리스트, 코드 등) — DocumentViewer 와 동일 출력 호환
|
|
*
|
|
* 사용:
|
|
* import { renderMathMarkdown } from '$lib/utils/mathMarkdown';
|
|
* <div class="prose prose-sm prose-invert max-w-none">{@html renderMathMarkdown(text)}</div>
|
|
*
|
|
* 주의:
|
|
* - 입력값은 그대로 저장. 변환은 표시 시점에만.
|
|
* - 수식 렌더 실패 시 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 전용 렌더 — <p> 같은 block 태그로 감싸지 않는다.
|
|
* 보기 1~4 처럼 줄 한 줄짜리 짧은 텍스트에 사용 (button 안에 <p>가 들어가면 레이아웃 깨짐).
|
|
*/
|
|
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: [] });
|
|
}
|
|
}
|