Merge pull request 'Feat/outline anchor' (#31) from feat/outline-anchor into main
Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
@@ -28,6 +28,9 @@
|
||||
mdStatus?: string | null;
|
||||
mdExtractionError?: string | null;
|
||||
mdExtractionQuality?: Record<string, unknown> | null;
|
||||
/** 개요 점프용 anchor: {chunk_id: md_content 내 char offset}. 렌더 전 해당 위치에
|
||||
* <span id="sec-{chunk_id}"> 주입(점프 타깃). buildAnchorMap(outlineAnchors) 산출물. */
|
||||
anchorMap?: Record<number, number> | null;
|
||||
placeholder?: string;
|
||||
/** 추가 래퍼 클래스. tailwind prose-* / spacing 등을 호출 측에서 입혀야 할 때. */
|
||||
class?: string;
|
||||
@@ -41,10 +44,27 @@
|
||||
mdStatus = null,
|
||||
mdExtractionError = null,
|
||||
mdExtractionQuality = null,
|
||||
anchorMap = null,
|
||||
placeholder = '*텍스트 추출 대기 중*',
|
||||
class: klass = '',
|
||||
}: Props = $props();
|
||||
|
||||
// 개요 anchor 주입: body 의 각 offset(내림차순)에 빈 <span id="sec-N"> 삽입(점프 타깃).
|
||||
// offset 은 buildAnchorMap 이 body 와 동일 문자열 기준으로 산출했어야 함(호출측 책임).
|
||||
function spliceAnchors(text: string, map: Record<number, number> | null): string {
|
||||
if (!map) return text;
|
||||
const ents = Object.entries(map)
|
||||
.map(([id, off]) => [id, Number(off)] as [string, number])
|
||||
.filter(([, o]) => Number.isFinite(o) && o >= 0 && o <= text.length)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
if (!ents.length) return text;
|
||||
let out = text;
|
||||
for (const [id, off] of ents) {
|
||||
out = out.slice(0, off) + `<span id="sec-${id}" class="md-anchor"></span>\n` + out.slice(off);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let usingMarkdown = $derived(!!(mdContent && mdContent.trim()));
|
||||
let body = $derived(
|
||||
usingMarkdown
|
||||
@@ -53,7 +73,7 @@
|
||||
? extractedText
|
||||
: placeholder,
|
||||
);
|
||||
let renderedHtml = $derived(renderDocMarkdown(body));
|
||||
let renderedHtml = $derived(renderDocMarkdown(spliceAnchors(body, anchorMap)));
|
||||
|
||||
let frontmatterEntries = $derived.by(() => {
|
||||
if (!usingMarkdown || !mdFrontmatter) return [] as [string, unknown][];
|
||||
|
||||
@@ -15,8 +15,12 @@
|
||||
|
||||
interface Props {
|
||||
sections: DocumentSection[];
|
||||
/** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */
|
||||
onJump?: (chunkId: number) => void;
|
||||
/** scroll-spy 현재 절(chunk_id) — 강조용. */
|
||||
activeKey?: number | null;
|
||||
}
|
||||
let { sections }: Props = $props();
|
||||
let { sections, onJump, activeKey = null }: Props = $props();
|
||||
|
||||
let layout = $derived(groupOrFlat(sections));
|
||||
let total = $derived(sections.length);
|
||||
@@ -37,15 +41,17 @@
|
||||
{#snippet itemRow(item: OutlineItem)}
|
||||
{@const s = item.section}
|
||||
{@const open = selectedId === s.chunk_id}
|
||||
{@const active = activeKey != null && activeKey === s.chunk_id}
|
||||
{@const typeLabel = sectionTypeLabel(s.section_type)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle(item)}
|
||||
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
|
||||
aria-expanded={open}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
class={[
|
||||
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors',
|
||||
open ? 'bg-surface-active text-text' : 'text-dim hover:bg-surface hover:text-text',
|
||||
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||||
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
|
||||
].join(' ')}
|
||||
>
|
||||
<span class="flex-1 min-w-0 leading-snug break-words">{title(s)}</span>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// 순수함수 회귀 테스트. 실행(로컬, 의존성 0): node --test src/lib/utils/outlineAnchors.test.ts
|
||||
// (Node ≥23 또는 22.6+ --experimental-strip-types — TS 타입 네이티브 strip.)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildAnchorMap } from './outlineAnchors.ts';
|
||||
import { type DocumentSection } from './headingPath.ts';
|
||||
|
||||
let _id = 0;
|
||||
function sec(p: Partial<DocumentSection>): DocumentSection {
|
||||
return {
|
||||
chunk_id: ++_id,
|
||||
section_title: null,
|
||||
heading_path: null,
|
||||
level: null,
|
||||
node_type: null,
|
||||
is_leaf: true,
|
||||
section_type: null,
|
||||
summary: null,
|
||||
confidence: null,
|
||||
...p,
|
||||
};
|
||||
}
|
||||
const md = (lines: string[]) => lines.join('\n');
|
||||
const lineOff = (lines: string[], idx: number) => {
|
||||
let o = 0;
|
||||
for (let i = 0; i < idx; i++) o += lines[i].length + 1;
|
||||
return o;
|
||||
};
|
||||
|
||||
test('ATX heading 정확 매칭 + offset', () => {
|
||||
const lines = ['# 개요', '본문 a', '## 설계 기준', '본문 b'];
|
||||
const s = [
|
||||
sec({ chunk_id: 101, section_title: '개요' }),
|
||||
sec({ chunk_id: 102, section_title: '설계 기준' }),
|
||||
];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
assert.equal(r.anchors[101], lineOff(lines, 0));
|
||||
assert.equal(r.anchors[102], lineOff(lines, 2));
|
||||
assert.equal(r.matched, 2);
|
||||
});
|
||||
|
||||
test('★ false early match 방어 — 상호참조가 heading 보다 먼저', () => {
|
||||
const lines = ['# 개요', '본 절은 Part UW 를 참조한다.', '내용', '# Part UW', '강판'];
|
||||
const s = [
|
||||
sec({ chunk_id: 1, section_title: '개요' }),
|
||||
sec({ chunk_id: 2, section_title: 'Part UW' }),
|
||||
];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
// 상호참조(line 1)가 아니라 실제 heading(line 3)으로
|
||||
assert.equal(r.anchors[2], lineOff(lines, 3));
|
||||
assert.notEqual(r.anchors[2], lineOff(lines, 1));
|
||||
});
|
||||
|
||||
test('중복 제목 — 단조 커서로 N번째 출현 매칭', () => {
|
||||
const lines = ['## General', 'a', '## Scope', 'b', '## General', 'c'];
|
||||
const s = [
|
||||
sec({ chunk_id: 1, section_title: 'General' }),
|
||||
sec({ chunk_id: 2, section_title: 'Scope' }),
|
||||
sec({ chunk_id: 3, section_title: 'General' }),
|
||||
];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
assert.equal(r.anchors[1], lineOff(lines, 0)); // 첫 General
|
||||
assert.equal(r.anchors[2], lineOff(lines, 2)); // Scope
|
||||
assert.equal(r.anchors[3], lineOff(lines, 4)); // 둘째 General (오점프 아님)
|
||||
});
|
||||
|
||||
test('prefix 가드 — 제1조 가 제1조의2 를 오매칭 안 함', () => {
|
||||
const lines = ['# 제1조의2', 'x', '# 제1조', 'y'];
|
||||
const s = [sec({ chunk_id: 1, section_title: '제1조' })];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
assert.equal(r.anchors[1], lineOff(lines, 2)); // 제1조의2(line0) 아님
|
||||
});
|
||||
|
||||
test('비-ATX 평문 제N조 (전체-라인 매칭)', () => {
|
||||
const lines = ['제1조(목적) 이 법은 OO 을 정한다.', '본문', '제2조(정의) 용어는...'];
|
||||
const s = [
|
||||
sec({ chunk_id: 1, section_title: '제1조(목적) 이 법은 OO 을 정한다.', node_type: 'clause' }),
|
||||
sec({ chunk_id: 2, section_title: '제2조(정의) 용어는...', node_type: 'clause' }),
|
||||
];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
assert.equal(r.anchors[1], lineOff(lines, 0));
|
||||
assert.equal(r.anchors[2], lineOff(lines, 2));
|
||||
});
|
||||
|
||||
test('window 조각 skip (anchor 없음)', () => {
|
||||
const lines = ['## 절', 'aaa', 'bbb'];
|
||||
const s = [
|
||||
sec({ chunk_id: 1, section_title: '절' }),
|
||||
sec({ chunk_id: 2, section_title: '절', node_type: 'window' }), // 부모 제목 상속 조각
|
||||
];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
assert.equal(r.anchors[1], lineOff(lines, 0));
|
||||
assert.equal(r.anchors[2], undefined); // window = 점프 비활성
|
||||
assert.equal(r.total, 1);
|
||||
});
|
||||
|
||||
test('코드펜스 내부 heading 제외', () => {
|
||||
const lines = ['```', '# General', '```', '# General', 'x'];
|
||||
const s = [sec({ chunk_id: 1, section_title: 'General' })];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
assert.equal(r.anchors[1], lineOff(lines, 3)); // 펜스 밖
|
||||
});
|
||||
|
||||
test('miss = anchor 없음 (점프 비활성, 오점프 아님)', () => {
|
||||
const lines = ['# 개요', '본문'];
|
||||
const s = [
|
||||
sec({ chunk_id: 1, section_title: '개요' }),
|
||||
sec({ chunk_id: 2, section_title: '존재하지 않는 절' }),
|
||||
];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
assert.equal(r.anchors[1], lineOff(lines, 0));
|
||||
assert.equal(r.anchors[2], undefined);
|
||||
assert.equal(r.total, 2);
|
||||
assert.equal(r.matched, 1);
|
||||
});
|
||||
|
||||
test('heading_path 마지막 세그먼트 fallback', () => {
|
||||
const lines = ['# 도입', 'x'];
|
||||
const s = [sec({ chunk_id: 1, section_title: null, heading_path: 'A > 도입' })];
|
||||
const r = buildAnchorMap(md(lines), s);
|
||||
assert.equal(r.anchors[1], lineOff(lines, 0));
|
||||
});
|
||||
|
||||
test('빈 입력 안전', () => {
|
||||
assert.deepEqual(buildAnchorMap('', [sec({ section_title: 'x' })]).anchors, {});
|
||||
assert.deepEqual(buildAnchorMap('# x', []).anchors, {});
|
||||
assert.deepEqual(buildAnchorMap(null, null).anchors, {});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// 개요(절 목차) → 본문 deterministic 점프용 anchor offset 산출 (경로 A: FE-only).
|
||||
//
|
||||
// hier 절(section_title)은 md_content 의 heading 라인에서 나왔으나(builder.py build_hier_tree,
|
||||
// md_content 순수함수), 비-ATX(제N조/Chapter)는 본문에 markdown heading 요소·id 가 안 생기고
|
||||
// 중복 제목(표-1·Part UW…)이 흔해 슬러그·textContent 매칭이 깨진다. 그래서 md_content 에서
|
||||
// 각 절의 heading 위치(char offset)를 직접 찾아 <a id="sec-{chunk_id}"> 를 주입할 좌표를 만든다.
|
||||
//
|
||||
// ★ false early match 방어 3중 (리뷰 반영):
|
||||
// 1. 라인-시작(전체-라인) 매칭 — 본문 중간 상호참조("see Part UW for…")는 라인 전체가 제목과
|
||||
// 같지 않으므로 제외. heading 라인(선두 #/리스트마커 제거 후 전체)만 매칭.
|
||||
// 2. 전체 매칭 + truncation 처리 — 'first-N-chars' prefix 금지('제1조'가 '제1조의2' 오매칭 차단).
|
||||
// builder 가 KO/ENG 제목을 [:200] truncate 하므로 truncated(매우 긴 제목)일 때만 startsWith.
|
||||
// 3. 단조 커서 + 코드펜스 회피 — 매칭은 직전 매칭 다음 라인부터(역행 불가) + ``` ~~~ 펜스 내부 제외.
|
||||
// 미스/역행은 anchor 없음 = 점프 비활성(아코디언 폴백). 오점프보다 무점프.
|
||||
//
|
||||
// ⚠ 잔여 한계: 본문 앞 '목차(TOC)'가 절 제목을 단독 라인으로 순서대로 나열하면 커서가 TOC 를
|
||||
// 먼저 잡을 수 있다(연쇄 시프트). 4-1 의 '정확도' 측정으로 검출 — 빈번하면 경로 B(builder offset).
|
||||
|
||||
import { cleanHeading, type DocumentSection } from './headingPath.ts';
|
||||
|
||||
const TRUNCATE_HINT = 180; // builder.py 가 KO/ENG 제목을 [:200] 으로 자름 → 거의 그 길이면 truncated 로 간주
|
||||
|
||||
function norm(s: string | null | undefined): string {
|
||||
return cleanHeading(s).toLowerCase();
|
||||
}
|
||||
|
||||
/** 한 라인을 heading 후보 텍스트로: 선두 ATX #(1~6) / 리스트마커(-*+) / blockquote(>) 제거 후 정규화. */
|
||||
function normLine(raw: string): string {
|
||||
const stripped = raw.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s+)?/, '');
|
||||
return cleanHeading(stripped).toLowerCase();
|
||||
}
|
||||
|
||||
export interface AnchorMapResult {
|
||||
/** chunk_id → md_content 내 heading 라인 시작 char offset. (없으면 점프 비활성) */
|
||||
anchors: Record<number, number>;
|
||||
/** 후보(비-window·제목有) 절 수 — 4-1 커버리지 분모. */
|
||||
total: number;
|
||||
/** 신뢰 anchor 수 — 4-1 커버리지 분자. (정확도는 별도 수작업 검증) */
|
||||
matched: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* sections 는 서버 chunk_index 순(문서 순서)으로 가정한다(GET /documents/{id}/sections ORDER BY).
|
||||
*/
|
||||
export function buildAnchorMap(
|
||||
mdContent: string | null | undefined,
|
||||
sections: DocumentSection[] | null | undefined,
|
||||
): AnchorMapResult {
|
||||
const anchors: Record<number, number> = {};
|
||||
if (!mdContent || !sections || sections.length === 0) {
|
||||
return { anchors, total: 0, matched: 0 };
|
||||
}
|
||||
|
||||
// 라인별 (offset, 정규화 텍스트, 펜스 여부) 사전계산.
|
||||
const rawLines = mdContent.split('\n');
|
||||
const lines: { off: number; norm: string }[] = [];
|
||||
let off = 0;
|
||||
let inFence = false;
|
||||
for (const raw of rawLines) {
|
||||
const fenceToggle = /^\s{0,3}(```|~~~)/.test(raw);
|
||||
const fencedHere = inFence || fenceToggle; // 펜스 경계 라인도 매칭 제외
|
||||
lines.push({ off, norm: fencedHere ? '' : normLine(raw) });
|
||||
if (fenceToggle) inFence = !inFence;
|
||||
off += raw.length + 1; // '\n'
|
||||
}
|
||||
|
||||
let cursor = 0; // 단조 전진 라인 인덱스
|
||||
let total = 0;
|
||||
let matched = 0;
|
||||
|
||||
for (const s of sections) {
|
||||
// window/section_split 조각은 자체 heading 없음(부모 제목 상속) → 건너뜀.
|
||||
if (s.node_type === 'window' || s.node_type === 'section_split') continue;
|
||||
let nt = norm(s.section_title);
|
||||
if (!nt && s.heading_path) {
|
||||
const last = s.heading_path.split('>').pop();
|
||||
nt = norm(last);
|
||||
}
|
||||
if (!nt) continue;
|
||||
total++;
|
||||
|
||||
const truncated = nt.length >= TRUNCATE_HINT;
|
||||
let foundIdx = -1;
|
||||
for (let i = cursor; i < lines.length; i++) {
|
||||
const ln = lines[i].norm;
|
||||
if (!ln) continue; // 빈 라인 / 펜스 내부
|
||||
if (ln === nt || (truncated && ln.startsWith(nt))) {
|
||||
foundIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundIdx >= 0) {
|
||||
anchors[s.chunk_id] = lines[foundIdx].off;
|
||||
cursor = foundIdx + 1; // 단조: 다음 절은 이 라인 이후만
|
||||
matched++;
|
||||
}
|
||||
// 미스 → anchor 없음(점프 비활성, 폴백)
|
||||
}
|
||||
|
||||
return { anchors, total, matched };
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
import { buildAnchorMap } from '$lib/utils/outlineAnchors';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -163,6 +164,45 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── 개요 점프 (outlineAnchors, 경로 A) ──
|
||||
// anchorMap = md_content 의 각 절 heading offset. MarkdownDoc 가 <span id="sec-N"> 주입.
|
||||
let anchorMap = $derived(
|
||||
hasSections && canShowMarkdown && doc?.md_content
|
||||
? buildAnchorMap(doc.md_content, sections).anchors
|
||||
: {}
|
||||
);
|
||||
let activeKey = $state(null);
|
||||
function jumpToSection(chunkId) {
|
||||
const el = document.getElementById(`sec-${chunkId}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
|
||||
$effect(() => {
|
||||
void anchorMap; // 문서/섹션 변화 시 재바인딩
|
||||
if (typeof window === 'undefined') return;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
let cur = null;
|
||||
document.querySelectorAll('.md-anchor').forEach((a) => {
|
||||
if (a.getBoundingClientRect().top <= 120) cur = a;
|
||||
});
|
||||
if (cur) {
|
||||
const m = cur.id.match(/^sec-(\d+)$/);
|
||||
if (m) activeKey = Number(m[1]);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
});
|
||||
|
||||
function getViewerType(format) {
|
||||
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
|
||||
if (format === 'pdf') return 'pdf';
|
||||
@@ -229,7 +269,7 @@
|
||||
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
|
||||
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
|
||||
<Card>
|
||||
<SectionOutline {sections} />
|
||||
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
|
||||
</Card>
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -240,7 +280,7 @@
|
||||
<!-- xl 미만: 절 목차 접이식 -->
|
||||
<details class="xl:hidden">
|
||||
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
|
||||
<Card class="mt-2"><SectionOutline {sections} /></Card>
|
||||
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
|
||||
</details>
|
||||
{/if}
|
||||
<!-- Affordance row -->
|
||||
@@ -289,6 +329,7 @@
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
anchorMap={anchorMap}
|
||||
extractedText={doc.extracted_text || rawMarkdown}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user