fabbca64e9
docMarked link 렌더러: http/https 링크에 target=_blank rel=noopener noreferrer (탭내빙 차단, 코퍼스 521건). 내부/'#'프래그먼트/상대/mailto 는 무손 — outline gfmHeadingId 경로 유지(클릭 인터셉터 없음=충돌 0). marked15 토큰객체 시그니처. SANITIZE_OPTS ADD_ATTR 에 target/rel. load-bearing 게이트: 상대 .md=코퍼스 0건·doc_key 부재 → path→id prop/document_links 미구현(dead). [[..]]=13건 대부분 인용 노이즈([[3\]]) → resolution/스트립 미구현. 외부 링크 하드닝만 정당화됨. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
7.2 KiB
TypeScript
166 lines
7.2 KiB
TypeScript
/**
|
|
* 문서 본문 markdown 렌더 (Phase 1C + Phase 1B.5 — MarkdownDoc 컴포넌트 전용).
|
|
*
|
|
* mathMarkdown.ts (study 의 문제·해설용) 와 별도 인스턴스를 둬서 study 측 동작에 영향 없음.
|
|
*
|
|
* 차이점:
|
|
* - GFM heading id (anchor 용 id 자동 부여, prefix=doc-)
|
|
* - 이미지는 placeholder card 로 렌더. `data-md-image-internal="1"` 인 경우 (href 가
|
|
* `docimg:img_NNN`) MarkdownDoc 의 selector 가 mount 후 실제 <img> 로 교체.
|
|
* `data-md-image-internal="0"` (외부 URL) 은 placeholder 유지 — 외부 이미지 자동
|
|
* fetch 회피 (privacy + dependency).
|
|
*
|
|
* KaTeX / DOMPurify 정책은 mathMarkdown.ts 의 정책과 동일.
|
|
*/
|
|
|
|
import DOMPurify from 'dompurify';
|
|
import { Marked } from 'marked';
|
|
import katex from 'katex';
|
|
// @ts-ignore — 타입 정의 누락 시 무시
|
|
import markedKatex from 'marked-katex-extension';
|
|
// @ts-ignore — 타입 정의 누락 시 무시
|
|
import { gfmHeadingId } from 'marked-gfm-heading-id';
|
|
import 'katex/dist/katex.min.css';
|
|
|
|
function escAttr(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
function escText(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
const docMarked = new Marked();
|
|
docMarked.use({ mangle: false } as any);
|
|
docMarked.use(gfmHeadingId({ prefix: 'doc-' }));
|
|
docMarked.use(
|
|
markedKatex({
|
|
throwOnError: false,
|
|
nonStandard: false,
|
|
output: 'html',
|
|
} as any),
|
|
);
|
|
|
|
// 이미지 → placeholder card. raw <img> 를 emit 하면 asset 미서빙 상태(1B.5 전)에선
|
|
// 깨진 아이콘이 보임 → 1D pilot 평가가 markdown 품질이 아닌 viewer 미완성 문제로 오염됨.
|
|
// 따라서 1C 에선 placeholder 로 렌더하되 원본 href 는 data-md-image-src 에 보존
|
|
// (1B.5 에서 ImgAuth selector 로 잡아 실제 <img> 로 교체 예정).
|
|
docMarked.use({
|
|
renderer: {
|
|
image(token: any): string {
|
|
const rawHref = (token?.href ?? '') as string;
|
|
const rawAlt = (token?.text ?? '') as string;
|
|
const isInternal = rawHref.startsWith('docimg:');
|
|
const basename = rawHref.split('/').pop() ?? rawHref;
|
|
const labelSrc = rawAlt || basename || '이미지';
|
|
const safeHref = escAttr(rawHref);
|
|
const safeAlt = escAttr(rawAlt);
|
|
const safeLabel = escText(`[이미지: ${labelSrc} — 아직 표시되지 않음]`);
|
|
const internalFlag = isInternal ? '1' : '0';
|
|
return (
|
|
`<figure class="md-image-placeholder" data-md-img="1" data-md-image-src="${safeHref}" data-md-image-internal="${internalFlag}" data-md-image-alt="${safeAlt}">` +
|
|
`<div class="md-image-placeholder-card">` +
|
|
`<span class="md-image-placeholder-icon" aria-hidden="true">🖼️</span>` +
|
|
`<span class="md-image-placeholder-label">${safeLabel}</span>` +
|
|
`</div>` +
|
|
`</figure>`
|
|
);
|
|
},
|
|
// 외부 링크(http/https) → 새 탭 + rel=noopener noreferrer (탭내빙 차단). 521건 실재.
|
|
// 내부/프래그먼트/상대 링크는 손대지 않음 — `#` anchor 는 gfmHeadingId/outline 경로 유지
|
|
// (클릭 인터셉터 없음 → 충돌 0), 상대 .md(코퍼스 0건)는 기본 동작(inert). marked 15 토큰객체 시그니처.
|
|
link(token: any): string {
|
|
const href = (token?.href ?? '') as string;
|
|
const text = this.parser.parseInline(token?.tokens ?? []);
|
|
const titleAttr = token?.title ? ` title="${escAttr(token.title as string)}"` : '';
|
|
const safeHref = escAttr(href);
|
|
if (/^https?:\/\//i.test(href)) {
|
|
return `<a href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
}
|
|
return `<a href="${safeHref}"${titleAttr}>${text}</a>`;
|
|
},
|
|
},
|
|
});
|
|
|
|
const SANITIZE_OPTS = {
|
|
USE_PROFILES: { html: true },
|
|
// KaTeX (style + aria-hidden), heading anchor (id), 이미지 마킹 (data-md-img,
|
|
// data-md-image-src + data-md-image-internal + data-md-image-alt — 1B.5 ImgAuth
|
|
// selector 용), figure caption (figure/figcaption)
|
|
ADD_ATTR: [
|
|
'style',
|
|
'aria-hidden',
|
|
'id',
|
|
'data-md-img',
|
|
'data-md-image-src',
|
|
'data-md-image-internal',
|
|
'data-md-image-alt',
|
|
'loading',
|
|
'target',
|
|
'rel',
|
|
],
|
|
ADD_TAGS: ['figure', 'figcaption'],
|
|
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link', 'meta'],
|
|
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover', 'onfocus'],
|
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
} as const;
|
|
|
|
// ── 수식 pre-render ──────────────────────────────────────────────────────────
|
|
// marked-katex-extension 의 토크나이저는 `$$` 가 블록 선두에 있어야 발화하는데,
|
|
// (1) 개요 anchor splice 가 `$$` 직전에 <span id="sec-N"> 를 끼우면 `$$` 가 문단 중간으로
|
|
// 밀려 블록 규칙이 깨지고, (2) 빌드/런타임 환경에 따라 확장 토크나이저가 발화하지 않으면
|
|
// `$$` 가 평문으로 새어 marked 의 백슬래시 이스케이프(\% → %, \, → ,)에 망가진다.
|
|
// → marked 가 손대기 *전에* 수식을 katex 로 직접 렌더해 placeholder 로 보호한 뒤 복원한다.
|
|
// 위치·인접 상황과 무관(전역 정규식)하므로 위 두 경우를 모두 우회한다.
|
|
const _MATH_SLOT = (i: number) => `KX0MATHSLOT${i}MATHKX0`; // marked-안전(영숫자) + 충돌 불가
|
|
const _MATH_SLOT_RE = /KX0MATHSLOT(\d+)MATHKX0/g;
|
|
const _BLOCK_MATH_RE = /\$\$([\s\S]+?)\$\$/g;
|
|
// 인라인 $...$ — 통화($5)·이스케이프(\$)·`$$` 회피. $ 직후 비공백, $ 직전 비공백.
|
|
const _INLINE_MATH_RE = /(?<![\\$\d])\$(?!\s)([^$\n]*?[^$\n\s])\$(?!\d)/g;
|
|
|
|
function _protectMath(text: string, slots: string[]): string {
|
|
const render = (tex: string, displayMode: boolean): string => {
|
|
slots.push(
|
|
katex.renderToString(tex.trim(), { displayMode, throwOnError: false, output: 'html' }),
|
|
);
|
|
return _MATH_SLOT(slots.length - 1);
|
|
};
|
|
return text
|
|
.replace(_BLOCK_MATH_RE, (m, tex) => {
|
|
try {
|
|
return render(String(tex), true);
|
|
} catch {
|
|
return m;
|
|
}
|
|
})
|
|
.replace(_INLINE_MATH_RE, (m, tex) => {
|
|
try {
|
|
return render(String(tex), false);
|
|
} catch {
|
|
return m;
|
|
}
|
|
});
|
|
}
|
|
|
|
export function renderDocMarkdown(text: string | null | undefined): string {
|
|
if (!text) return '';
|
|
try {
|
|
const slots: string[] = [];
|
|
const protectedText = _protectMath(text, slots);
|
|
let html = docMarked.parse(protectedText) as string;
|
|
if (slots.length) {
|
|
// 블록 수식이 단독 문단이면 marked 가 <p> 로 감싸므로 그 <p> 를 벗겨 블록 수식이 문단에
|
|
// 매몰되지 않게 한다. (katex-display 는 block 이라 <p> 안에 두면 브라우저가 자동 분리.)
|
|
html = html
|
|
.replace(
|
|
new RegExp(`<p>\\s*KX0MATHSLOT(\\d+)MATHKX0\\s*</p>`, 'g'),
|
|
(m, i) => slots[Number(i)] ?? m,
|
|
)
|
|
.replace(_MATH_SLOT_RE, (m, i) => slots[Number(i)] ?? m);
|
|
}
|
|
return DOMPurify.sanitize(html, SANITIZE_OPTS);
|
|
} catch {
|
|
// 마지막 안전망: 모든 태그 제거 후 escape
|
|
return DOMPurify.sanitize(text, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
|
|
}
|
|
}
|