Files
hyungi_document_server/frontend/src/lib/components/MarkdownDoc.svelte
T
Hyungi Ahn d6e0f5de04 feat(frontend): Phase 1C — markdown viewer 완성 (PDF 통합 + status badge + image placeholder)
Phase 1B marker_worker 결과(현재 success 29건, 전부 PDF)를 사용자 흐름에
연결하고 1D pilot 품질 평가 데이터를 확보하기 위한 viewer 마무리 작업.

빠진 부분 3가지를 닫는다:

1) PDF viewerType 기본 view = Markdown
   - md_status='success' AND md_content 비어있지 않음일 때 MarkdownDoc 기본 표시.
   - 사용자가 "PDF 원본" 토글 시 iframe.
   - pdfViewMode 초기화는 doc.id 변경 시에만 (lastDocId tracker) — reactive cycle
     이 사용자 토글을 덮어쓰지 않도록 보호.
   - markdown 사라지는 케이스(success → failed 재처리)는 자동으로 pdf 로 보호.

2) Image renderer → placeholder card (docMarkdown.ts)
   - md_content 의 69%(20/29)에 image syntax 포함. asset serving(1B.5) 미구현
     상태에서 raw <img> 를 emit 하면 깨진 아이콘 → 1D pilot 평가가 markdown
     품질이 아닌 viewer 미완성 문제로 오염됨.
   - href / alt / basename 모두 escape 후 figure.md-image-placeholder 로 렌더.
   - 원본 src 는 data-md-image-src 에 escape 보존 → 1B.5 ImgAuth selector 로
     실제 <img> 로 교체할 entry point 마련.
   - DOMPurify ADD_ATTR 에 data-md-image-src 추가.

3) MarkdownStatusBadge (신규) — 4-state badge
   - pending 숨김(legacy 9792건 시각 노이즈 회피).
   - processing/success/skipped/failed 표시.
   - success tooltip: md_extraction_quality 의 metrics raw 일부
     (markdown_heading_count / markdown_table_row_count / markdown_image_count /
     text_length_ratio / warnings) 만 노출. text_length_ratio / null /
     metrics nested / flat fallback 모두 방어.
   - skipped/failed tooltip: md_extraction_error 또는 정책 문구.
   - MarkdownDoc 내부 + PDF iframe fallback 양쪽에서 재사용 → failed 같이
     MarkdownDoc 가 안 렌더되는 경로에서도 사용자가 상태를 알 수 있음.

기존 markdown/hwp-markdown/article 분기에도 mdExtractionQuality prop 전달.

Out of scope (1B.5 또는 후속):
- ImgAuth blob URL 실제 wiring (data-md-image-src selector + Bearer raw)
- /data/assets/<doc_id>/ 저장 + 서빙
- Caddy /data/assets/* 라우팅
- localStorage 사용자 view preference 저장
- side-by-side viewer (1D pilot 결과 본 후)
- quality chip 별도 UI (1D 후)

Verify:
- npm run build 통과
- npm run lint:tokens 신규 파일 위반 0
- 관련 plan: ~/.claude/plans/iterative-nibbling-catmull.md
- pre-flight: md_extraction_quality 실제 shape 확인 ({score, metrics:{...}, warnings:[]})

Risks:
- feature/design-system worktree 가 [id]/+page.svelte 의 stale 버전 보유
  (main 보다 212 commits behind, MarkdownDoc 부재). 1C 머지 후 worktree
  머지 시 conflict 확정 — 그쪽 rebase 필요 (별건).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:38:45 +09:00

120 lines
3.8 KiB
Svelte

<script lang="ts">
/**
* 문서 본문 canonical markdown 렌더러 (Phase 1C).
*
* 우선순위:
* 1. md_content (Phase 1B 의 marker_worker 가 채운 canonical markdown)
* 2. extracted_text fallback (기존 본문)
* 3. placeholder
*
* 기능:
* - md_frontmatter (JSONB) 가 비어있지 않으면 본문 위에 메타 박스
* - heading anchor (h1~h6 id 자동 부여 + # 링크)
* - 이미지는 placeholder card 로 렌더 (1B.5 ImgAuth wiring 전)
* - KaTeX ($...$ inline / $$...$$ block)
* - DOMPurify sanitize
* - md_status badge (processing/success/skipped/failed) — MarkdownStatusBadge 위임
*/
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
type Props = {
mdContent?: string | null;
mdFrontmatter?: Record<string, unknown> | null;
extractedText?: string | null;
mdStatus?: string | null;
mdExtractionError?: string | null;
mdExtractionQuality?: Record<string, unknown> | null;
placeholder?: string;
/** 추가 래퍼 클래스. tailwind prose-* / spacing 등을 호출 측에서 입혀야 할 때. */
class?: string;
};
let {
mdContent = null,
mdFrontmatter = null,
extractedText = null,
mdStatus = null,
mdExtractionError = null,
mdExtractionQuality = null,
placeholder = '*텍스트 추출 대기 중*',
class: klass = '',
}: Props = $props();
let usingMarkdown = $derived(!!(mdContent && mdContent.trim()));
let body = $derived(
usingMarkdown
? (mdContent as string)
: extractedText && extractedText.trim()
? extractedText
: placeholder,
);
let renderedHtml = $derived(renderDocMarkdown(body));
let frontmatterEntries = $derived.by(() => {
if (!usingMarkdown || !mdFrontmatter) return [] as [string, unknown][];
return Object.entries(mdFrontmatter).filter(
([, v]) => v !== null && v !== undefined && v !== '',
);
});
function formatValue(v: unknown): string {
if (v === null || v === undefined) return '';
if (typeof v === 'object') {
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
return String(v);
}
let containerRef: HTMLDivElement | undefined = $state();
// heading anchor 후처리 — gfmHeadingId 가 부여한 id 에 # 링크 prepend.
// string-level 정규식 처리 대신 DOM 후처리 — id 에 따옴표/HTML 메타 들어와도 안전.
$effect(() => {
// renderedHtml 변할 때마다 재실행
void renderedHtml;
if (!containerRef) return;
const headings = containerRef.querySelectorAll<HTMLHeadingElement>(
'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]',
);
for (const h of headings) {
if (h.querySelector('a.heading-anchor')) continue;
const id = h.getAttribute('id');
if (!id) continue;
const a = document.createElement('a');
a.className = 'heading-anchor';
a.setAttribute('href', `#${id}`);
a.setAttribute('aria-label', '이 항목으로 링크');
a.textContent = '#';
h.insertBefore(a, h.firstChild);
}
});
</script>
<div class="mb-2">
<MarkdownStatusBadge {mdStatus} {mdExtractionError} {mdExtractionQuality} />
</div>
{#if frontmatterEntries.length}
<dl
class="md-frontmatter mb-4 grid grid-cols-[max-content,1fr] gap-x-3 gap-y-1 rounded border border-border/60 bg-bg/40 px-4 py-3 text-xs"
>
<div class="col-span-2 mb-1 text-[10px] uppercase tracking-wide text-dim">메타</div>
{#each frontmatterEntries as [k, v] (k)}
<dt class="text-dim">{k}</dt>
<dd class="break-words text-text">{formatValue(v)}</dd>
{/each}
</dl>
{/if}
<div
bind:this={containerRef}
class="markdown-body markdown-doc leading-relaxed {klass}"
>
{@html renderedHtml}
</div>