Files
hyungi_document_server/frontend/src/lib/utils/docMarkdown.ts
T
hyungi fabbca64e9 feat(markdown): 외부 링크 새 탭 + rel=noopener noreferrer (P0)
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>
2026-06-15 15:06:58 +09:00

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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escText(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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: [] });
}
}