d6e0f5de04
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>
120 lines
3.8 KiB
Svelte
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>
|