Files
ABAP-Indexing/wiki_out/viewer.html
T

483 lines
29 KiB
HTML

<!-- ABAP LLM 위키 뷰어 템플릿. 플레이스홀더는 DATA_JSON / GENERATED_AT 를 밑줄 두 개로 감싼 토큰.
이 주석에는 그 토큰도, HTML 주석 닫기 기호(하이픈 두 개 + 꺾쇠)도 적지 말 것 — 치환된 데이터가
주석 안에 박히고 ABAP 주석 화살표가 이 주석을 조기에 닫아 화면이 깨진 적이 있다 (2026-09-17).
빌드: python -m wiki_out.viewer [--out path] / 라이브: GET /wiki-viewer -->
<title>ABAP LLM 위키</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root{
--bg:#F6F6F2; --surface:#FFFFFF; --surface2:#EFEFE9; --ink:#232527; --muted:#6C7075;
--line:#E2E2DA; --accent:#0E7490; --accent-deep:#0B5D74; --accent-bg:#E3F0F4;
--track:#E6E6DF; --ok:#15803D; --ok-bg:#E5F3E9; --warn:#96560F; --warn-bg:#F7EDDD;
--code-bg:#F0F0EA; --shadow:0 1px 3px rgba(35,37,39,.07);
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#17181A; --surface:#1F2124; --surface2:#26282C; --ink:#E8E8E4; --muted:#9DA1A6;
--line:#33363B; --accent:#4FB8D3; --accent-deep:#7FCBDE; --accent-bg:#1C2B31;
--track:#2C2F33; --ok:#6FBE86; --ok-bg:#1E2E23; --warn:#D9A05B; --warn-bg:#322718;
--code-bg:#26282C; --shadow:0 1px 3px rgba(0,0,0,.35);
}
}
:root[data-theme="dark"]{
--bg:#17181A; --surface:#1F2124; --surface2:#26282C; --ink:#E8E8E4; --muted:#9DA1A6;
--line:#33363B; --accent:#4FB8D3; --accent-deep:#7FCBDE; --accent-bg:#1C2B31;
--track:#2C2F33; --ok:#6FBE86; --ok-bg:#1E2E23; --warn:#D9A05B; --warn-bg:#322718;
--code-bg:#26282C; --shadow:0 1px 3px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:400 14px/1.65 "IBM Plex Sans KR",'Apple SD Gothic Neo','Malgun Gothic',sans-serif;}
.mono{font-family:"IBM Plex Mono",Consolas,monospace}
a{color:var(--accent-deep)}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0;text-align:left}
button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
header.app{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;
padding:14px 20px 12px;border-bottom:1px solid var(--line);background:var(--surface)}
header.app h1{margin:0;font-size:17px;font-weight:700;letter-spacing:-.01em}
header.app h1 button{font:inherit;font-weight:700;letter-spacing:inherit;cursor:pointer}
header.app h1 button:hover .accent{text-decoration:underline}
header.app h1 .accent{color:var(--accent-deep)}
header.app .sub{color:var(--muted);font-size:12px}
header.app .home-btn{margin-left:auto;font-size:12px;color:var(--accent-deep);font-weight:500}
.shell{display:grid;grid-template-columns:290px minmax(0,1fr);min-height:calc(100vh - 50px)}
@media(max-width:820px){.shell{grid-template-columns:1fr}.side{border-right:0;border-bottom:1px solid var(--line);max-height:45vh}}
/* ---------- 사이드바 ---------- */
.side{border-right:1px solid var(--line);background:var(--surface);overflow-y:auto;padding:12px 0 32px}
.side .search{margin:0 14px 10px}
.side input{width:100%;padding:7px 10px;border:1px solid var(--line);border-radius:6px;
background:var(--bg);color:var(--ink);font:inherit;font-size:13px}
.side .sec{margin:14px 0 4px;padding:0 16px;font-size:11px;font-weight:700;letter-spacing:.08em;
text-transform:uppercase;color:var(--muted)}
.side .sec-toggle{display:flex;align-items:center;gap:7px;width:100%;text-align:left;cursor:pointer}
.side .sec-toggle:hover{color:var(--ink)}
.side .sec-toggle .cnt{margin-left:auto;text-transform:none;letter-spacing:0}
.nav-item{display:flex;align-items:center;gap:7px;width:100%;padding:4px 16px;font-size:13px;line-height:1.4}
.nav-item:hover{background:var(--surface2)}
.nav-item.active{background:var(--accent-bg);color:var(--accent-deep);font-weight:500}
.nav-item .cnt{margin-left:auto;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}
/* 프로그램 행: ▶ 는 펼치기, 이름은 문서 열기 — 역할이 다르므로 버튼을 나눈다 */
.prog-nav{display:flex;align-items:center;gap:4px;padding:0 10px 0 12px}
.prog-nav:hover{background:var(--surface2)}
.prog-nav .tw-btn{flex:none;width:18px;height:26px;display:flex;align-items:center;justify-content:center;border-radius:4px}
.prog-nav .tw-btn:hover{background:var(--line)}
.prog-nav .nav-name{flex:1;min-width:0;padding:4px 6px;font-family:"IBM Plex Mono",monospace;font-size:12.5px;
border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.prog-nav .nav-name:hover{color:var(--accent-deep)}
.prog-nav .nav-name.active{background:var(--accent-bg);color:var(--accent-deep);font-weight:500}
.prog-nav .cnt{flex:none;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums;padding-right:4px}
.tw{display:inline-block;width:10px;color:var(--muted);font-size:10px;transition:transform .12s}
@media(prefers-reduced-motion:reduce){.tw{transition:none}}
.tw.open{transform:rotate(90deg)}
.units{margin:0;padding:0 0 4px}
.unit-item{display:flex;gap:7px;width:100%;padding:2px 16px 2px 33px;font-size:12px;color:var(--muted)}
.unit-item:hover{background:var(--surface2)}
.unit-item.active{background:var(--accent-bg);color:var(--accent-deep)}
.unit-item .nm{font-family:"IBM Plex Mono",monospace;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.utype{flex:none;width:44px;font-size:9.5px;font-weight:500;letter-spacing:.04em;color:var(--muted)}
.unit-item .cnt{margin-left:auto}
.unit-empty{padding:3px 16px 5px 33px;font-size:11px;color:var(--muted);opacity:.7}
/* 사이드바의 조각 항목 — kind 색으로 종류를 구분한다 */
.chunk-item{display:flex;gap:6px;align-items:baseline;width:100%;padding:2px 14px 2px 44px;
font-size:11.5px;color:var(--muted);text-align:left}
.chunk-item:hover{background:var(--surface2);color:var(--ink)}
.chunk-item .cp{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.45}
.chunk-item .kind{flex:none;font-size:9px;font-weight:600;letter-spacing:.03em;padding:1px 4px;
border-radius:3px;background:var(--surface2);border:1px solid var(--line);text-transform:uppercase}
.kind.k-sql_select{color:#0b6bb8;border-color:#9dc7e8}
.kind.k-db_write{color:#b0480b;border-color:#e8b79d}
.kind.k-fm_call{color:#6b3fb8;border-color:#c0a9e8}
.kind.k-validation{color:#0a7b52;border-color:#9de0c2}
.kind.k-interface{color:#b80b6b;border-color:#e89dc7}
.kind.k-aggregation,.kind.k-calculation{color:#8a6d0b;border-color:#e0d09d}
.kind.k-control_flow{color:#555;border-color:var(--line)}
h3.flash,h4.flash{animation:fl 1.4s ease-out}
@keyframes fl{0%{background:var(--accent);color:#fff}100%{background:transparent}}
.dot{flex:none;width:6px;height:6px;border-radius:50%;margin-top:6px;background:var(--track)}
.dot.done{background:var(--accent)}
.results .nav-item{align-items:flex-start}
.results .meta{font-size:11px;color:var(--muted)}
/* ---------- 본문 ---------- */
.main{padding:22px 28px 60px;min-width:0}
.wrap{max-width:860px}
/* 개요 */
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:10px;margin:6px 0 24px}
.tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;box-shadow:var(--shadow)}
.tile .v{font-size:24px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.15}
.tile .l{font-size:11.5px;color:var(--muted);margin-top:2px;white-space:nowrap}
.tile.hero .v{color:var(--accent-deep)}
h2.sec-h{font-size:14px;font-weight:700;margin:26px 0 10px;display:flex;align-items:baseline;gap:10px}
h2.sec-h .hint{font-weight:400;color:var(--muted);font-size:12px}
/* 프로그램 목록 — 한 줄에 하나, 타이틀은 한 줄로 자른다 */
.plist{background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:var(--shadow)}
.plist .ph,.plist .prow{display:grid;grid-template-columns:130px minmax(0,1fr) 150px 56px;gap:14px;align-items:center;padding:0 14px}
.plist .ph{font-size:11px;font-weight:500;color:var(--muted);letter-spacing:.03em;background:var(--surface2);
border-bottom:1px solid var(--line);height:32px}
.plist .prow{width:100%;height:40px;border-bottom:1px solid var(--line);text-align:left}
.plist .prow:last-child{border-bottom:0}
.plist .prow:hover{background:var(--accent-bg)}
.plist .pn{font-family:"IBM Plex Mono",monospace;font-size:13px;font-weight:500;color:var(--accent-deep)}
.plist .pt{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plist .pt .draft{font-size:10px;color:var(--warn);border:1px solid var(--warn);border-radius:99px;padding:0 6px;margin-left:6px;vertical-align:1px}
.plist .num{text-align:right;font-variant-numeric:tabular-nums;font-size:12.5px;color:var(--muted)}
.meter{display:flex;align-items:center;gap:8px}
.meter .bar{display:block;flex:1;height:6px;border-radius:3px;background:var(--track);overflow:hidden}
.meter .fill{display:block;height:100%;border-radius:3px;background:var(--accent)}
.meter .lab{flex:none;width:26px;text-align:right;font-size:12.5px;font-variant-numeric:tabular-nums;font-weight:500}
@media(max-width:700px){.plist .ph,.plist .prow{grid-template-columns:110px minmax(0,1fr) 90px}.plist .num,.plist .ph .num{display:none}}
.note{font-size:12px;color:var(--muted);margin:14px 0 0}
/* 문서 뷰 */
.doc-head{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:18px}
.crumb{font-size:11.5px;color:var(--muted);font-family:"IBM Plex Mono",monospace;margin-bottom:6px}
.doc-head h2{margin:0 0 8px;font-size:21px;font-weight:700;letter-spacing:-.015em;text-wrap:balance}
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.chip{font-size:11px;font-weight:500;padding:2px 8px;border-radius:99px;border:1px solid var(--line);color:var(--muted);background:var(--surface)}
.chip.type{color:var(--accent-deep);border-color:var(--accent);background:var(--accent-bg)}
.chip.ok{color:var(--ok);border-color:var(--ok);background:var(--ok-bg)}
.chip.warn{color:var(--warn);border-color:var(--warn);background:var(--warn-bg)}
.gen{font-size:11.5px;color:var(--muted);margin-top:8px}
.gen .mono{font-size:11px}
.md{max-width:760px}
.md h2{font-size:16px;font-weight:700;margin:24px 0 8px;padding-top:14px;border-top:1px solid var(--line)}
.md h2:first-child{border-top:0;padding-top:0;margin-top:8px}
.md h3{font-size:14px;font-weight:700;margin:18px 0 6px}
.md p{margin:8px 0;max-width:70ch}
.md ul,.md ol{margin:8px 0;padding-left:22px}
.md li{margin:3px 0}
.md code{font-family:"IBM Plex Mono",monospace;font-size:12.5px;background:var(--code-bg);
padding:1px 5px;border-radius:4px}
.md blockquote{margin:10px 0;padding:6px 12px;border-left:3px solid var(--warn);
background:var(--warn-bg);border-radius:0 6px 6px 0;color:var(--ink);font-size:13px}
.md .tbl{overflow-x:auto;margin:10px 0;border:1px solid var(--line);border-radius:8px}
.md table{border-collapse:collapse;width:100%;font-size:12.5px}
.md th{text-align:left;font-weight:500;color:var(--muted);font-size:11.5px;letter-spacing:.03em;
padding:7px 12px;border-bottom:1px solid var(--line);background:var(--surface2);white-space:nowrap}
.md td{padding:6px 12px;border-bottom:1px solid var(--line);vertical-align:top}
.md tr:last-child td{border-bottom:0}
.md td:first-child{font-family:"IBM Plex Mono",monospace;font-size:12px;white-space:nowrap}
.md a.doc-link{color:var(--accent-deep);text-decoration:none;border-bottom:1px dashed var(--accent)}
.md a.doc-link:hover{border-bottom-style:solid}
.md a.dead{color:var(--muted);border-bottom:1px dotted var(--line);text-decoration:none;cursor:default}
.md a.jump{color:var(--accent-deep);text-decoration:none;border-bottom:1px dashed var(--accent);cursor:pointer}
.md a.jump:hover{border-bottom-style:solid;background:var(--accent-bg,var(--surface2))}
.empty{color:var(--muted);padding:40px 0;text-align:center}
/* ---- 로직 조각: 소스 원문 + 설명 ---- */
.md{max-width:980px}
.md h4.chunk-h{font-size:13.5px;font-weight:600;margin:20px 0 8px;padding:8px 12px;
background:var(--accent-bg,var(--surface2));border-left:3px solid var(--accent);
border-radius:0 6px 6px 0;line-height:1.5}
.md h4.decl-h{font-size:13px;font-weight:600;margin:18px 0 6px;padding:5px 10px;
background:var(--surface2);border-left:3px solid var(--track,var(--line));border-radius:0 6px 6px 0;
font-family:"IBM Plex Mono",monospace;scroll-margin-top:12px}
.md pre.code{margin:0 0 10px;padding:10px 0;background:var(--code-bg);border:1px solid var(--line);
border-radius:8px;overflow-x:auto}
.md pre.code code{display:block;background:none;padding:0 14px;border-radius:0;
font-family:"IBM Plex Mono",monospace;font-size:12px;line-height:1.65;white-space:pre;tab-size:2}
.md pre.code .ln{display:inline-block;min-width:62px;color:var(--muted);opacity:.65;
user-select:none;text-align:right;padding-right:10px}
.md pre.code .k{color:var(--accent-deep);font-weight:600}
.md pre.code .s{color:#0a7b52}
.md pre.code .c{color:var(--muted);font-style:italic}
@media (prefers-color-scheme:dark){
.md pre.code .s{color:#5fd4a0}
}
</style>
<header class="app">
<h1><button id="homeTitle" title="개요로 돌아가기"><span class="accent">ABAP</span> LLM 위키</button></h1>
<span class="sub">스냅샷 __GENERATED_AT__</span>
<button class="home-btn" id="homeBtn">개요로</button>
</header>
<div class="shell">
<nav class="side" id="side" aria-label="위키 탐색">
<div class="search"><input id="q" type="search" placeholder="프로그램·unit·태그 검색" aria-label="검색"></div>
<div id="tree"></div>
</nav>
<main class="main"><div class="wrap" id="view"></div></main>
</div>
<script type="application/json" id="data">__DATA_JSON__</script>
<script>
const D = JSON.parse(document.getElementById('data').textContent);
const view = document.getElementById('view');
const tree = document.getElementById('tree');
const esc = s => String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
/* ABAP 간이 하이라이트 — ' 123| CODE' 형태의 줄번호를 분리해 표시한다 */
const ABAP_KW = new RegExp('\\b('+[
'SELECT','FROM','INTO','TABLE','WHERE','AND','OR','NOT','APPENDING','FOR','ALL','ENTRIES',
'INSERT','UPDATE','MODIFY','DELETE','COMMIT','ROLLBACK','WORK','APPEND','COLLECT','READ',
'LOOP','ENDLOOP','AT','IF','ELSEIF','ELSE','ENDIF','CASE','WHEN','ENDCASE','DO','ENDDO',
'WHILE','ENDWHILE','CHECK','EXIT','CONTINUE','RETURN','LEAVE','FORM','ENDFORM','PERFORM',
'CALL','FUNCTION','METHOD','ENDMETHOD','MODULE','ENDMODULE','EXPORTING','IMPORTING','CHANGING',
'TABLES','EXCEPTIONS','USING','DATA','TYPES','CONSTANTS','CLEAR','REFRESH','FREE','SORT',
'MOVE-CORRESPONDING','CONCATENATE','DESCRIBE','LINES','AUTHORITY-CHECK','OBJECT','ID','FIELD',
'MESSAGE','WITH','DISPLAY','LIKE','TYPE','VALUE','SPACE','INITIAL','IS','EQ','NE','GT','LT',
'DESTINATION','TRANSPORTING','KEY','INDEX','BY','OF','TO','ENDSELECT','SY-SUBRC'
].join('|')+')\\b','g');
function hlAbap(raw){
const m = raw.match(/^(\s*\d+\|)(.*)$/);
const no = m ? m[1] : '';
let code = m ? m[2] : raw;
code = esc(code);
/* 주석을 먼저 떼낸다 — 주석 안의 ABAP 키워드는 칠하지 않는다.
전체주석(줄 첫 문자 '*')과 인라인주석('"' → esc 후 &quot;)을 나눠 처리한다. */
let comment = '';
if (/^\s*\*/.test(code)) { comment = code; code = ''; }
else {
const q = code.indexOf('&quot;');
if (q >= 0) { comment = code.slice(q); code = code.slice(0, q); }
}
code = code.replace(/(&#39;|')([^']*)(&#39;|')/g, '<span class="s">$1$2$3</span>')
.replace(ABAP_KW, '<span class="k">$1</span>');
return (no?`<span class="ln">${esc(no)}</span>`:'') + code +
(comment?`<span class="c">${comment}</span>`:'');
}
/* ---------- 미니 마크다운 렌더러 ---------- */
function inline(s){
s = esc(s);
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, t, href) => {
if (href.startsWith('/')) {
const p = href.replace(/^\//,'').replace(/\/$/,'');
const key = D.docs[p] ? p : (D.docs[p + '.md'] ? p + '.md' : null);
return key ? `<a class="doc-link" href="#" data-doc="${esc(key)}">${t}</a>`
: `<a class="dead" title="문서 없음(스냅샷 미포함)">${t}</a>`;
}
/* 문서 안 앵커(#decl-...) — 조각의 정의부 이름에서 아래 선언 원문으로 뛴다 */
if (href.startsWith('#')) return `<a class="jump" href="${esc(href)}" data-jump-id="${esc(href.slice(1))}">${t}</a>`;
return `<a class="dead">${t}</a>`;
});
return s;
}
/* 제목 끝의 `{#id}` = 앵커 표기. 마크다운으로 읽을 때는 잡음이 적고, 여기서는 id 가 된다. */
const HEAD_ID=/\s*\{#([^}\s]+)\}\s*$/;
function headId(text){
const m=text.match(HEAD_ID);
return m ? [text.slice(0, m.index), m[1]] : [text, ''];
}
function renderMd(src){
const out=[]; const lines=src.split('\n'); let i=0, list=null;
const closeList=()=>{ if(list){out.push(list==='ul'?'</ul>':'</ol>'); list=null;} };
while(i<lines.length){
const ln=lines[i];
/* 코드펜스 — 로직 조각의 소스 원문. 이게 없으면 코드가 한 줄씩 <p> 로 흩어진다. */
if(/^```/.test(ln)){
closeList();
const lang=ln.slice(3).trim(); const buf=[]; i++;
while(i<lines.length && !/^```/.test(lines[i])){ buf.push(lines[i]); i++; }
i++; /* 닫는 펜스 */
out.push(`<pre class="code${lang?' lang-'+esc(lang):''}"><code>`+
buf.map(l=>hlAbap(l)).join('\n')+'</code></pre>');
continue;
}
if(/^\s*$/.test(ln)){ closeList(); i++; continue; }
if(/^####\s/.test(ln)){ closeList();
const [txt,id]=headId(ln.slice(5));
const cls=id.startsWith('decl-')?'decl-h':'chunk-h'; /* 정의부 항목은 조각 제목과 다른 모양 */
out.push(`<h4 class="${cls}"${id?` id="${esc(id)}"`:''}>`+inline(txt)+'</h4>'); i++; continue; }
if(/^###\s/.test(ln)){ closeList();
const [txt,id]=headId(ln.slice(4));
out.push(`<h3${id?` id="${esc(id)}"`:''}>`+inline(txt)+'</h3>'); i++; continue; }
if(/^##\s/.test(ln)){ closeList(); out.push('<h2>'+inline(ln.slice(3))+'</h2>'); i++; continue; }
if(/^>\s?/.test(ln)){ closeList(); out.push('<blockquote>'+inline(ln.replace(/^>\s?/,''))+'</blockquote>'); i++; continue; }
if(/^\|/.test(ln)){
closeList(); const rows=[];
while(i<lines.length && /^\|/.test(lines[i])){ rows.push(lines[i]); i++; }
const cells=r=>r.replace(/^\||\|$/g,'').split('|').map(c=>c.trim());
let html='<div class="tbl"><table>';
const head=cells(rows[0]); const hasSep=rows[1]&&/^[\s|:-]+$/.test(rows[1]);
html+='<thead><tr>'+head.map(c=>'<th>'+inline(c)+'</th>').join('')+'</tr></thead><tbody>';
for(let r=hasSep?2:1; r<rows.length; r++)
html+='<tr>'+cells(rows[r]).map(c=>'<td>'+inline(c)+'</td>').join('')+'</tr>';
out.push(html+'</tbody></table></div>'); continue;
}
if(/^[-*]\s/.test(ln)){ if(list!=='ul'){closeList(); out.push('<ul>'); list='ul';}
out.push('<li>'+inline(ln.replace(/^[-*]\s/,''))+'</li>'); i++; continue; }
if(/^\d+\.\s/.test(ln)){ if(list!=='ol'){closeList(); out.push('<ol>'); list='ol';}
out.push('<li>'+inline(ln.replace(/^\d+\.\s/,''))+'</li>'); i++; continue; }
closeList(); out.push('<p>'+inline(ln)+'</p>'); i++;
}
closeList(); return out.join('\n');
}
/* ---------- 화면: 개요 ---------- */
function showHome(){
setActive(null);
const s=D.stats;
const maxc = Math.max(1, ...D.programs.map(p=>p.chunks||0));
let h=`<div class="tiles">
<div class="tile"><div class="v">${s.programs}</div><div class="l">프로그램</div></div>
<div class="tile hero"><div class="v">${(s.chunks||0).toLocaleString()}</div><div class="l">로직 조각</div></div>
<div class="tile"><div class="v">${(s.units_total||0).toLocaleString()}</div><div class="l">조각 있는 unit</div></div>
<div class="tile"><div class="v">${(s.tables||0)}</div><div class="l">테이블</div></div>
<div class="tile"><div class="v">${(s.functions||0)}</div><div class="l">펑션</div></div>
<div class="tile"><div class="v">${s.human_verified}</div><div class="l">사람 검토</div></div>
</div>
<h2 class="sec-h">프로그램 <span class="hint">행을 누르면 인덱싱 결과(요약 · 로직 조각)를 봅니다</span></h2>
<div class="plist">
<div class="ph"><span>프로그램</span><span>타이틀</span><span>로직 조각</span><span class="num">unit</span></div>`;
for(const p of D.programs){
const c = p.chunks||0;
const w = Math.round(100*c/maxc);
const draft = p.status==='draft' ? '<span class="draft">초안</span>' : '';
h+=`<button class="prow" data-doc="${esc(p.path)}" title="${esc(p.desc||p.title)}">
<span class="pn">${esc(p.name)}</span>
<span class="pt">${esc(p.title)}${draft}</span>
<span class="meter"><span class="bar"><span class="fill" style="width:${w}%"></span></span><span class="lab">${c}</span></span>
<span class="num">${p.total}</span></button>`;
}
h+=`</div>
<p class="note">로직 조각 = LLM 이 코드에서 골라낸 업무 로직 단위 (소스 원문 + 설명). 테이블·호출은 파서가 뽑은 사실이며,
"어디서 채워지나 · 누가 호출하나"는 trace / call-graph / usage 도구로 확인합니다.</p>`;
view.innerHTML=h;
}
/* ---------- 화면: 문서 ---------- */
function showDoc(path){
const d=D.docs[path]; if(!d){ view.innerHTML='<div class="empty">문서를 찾을 수 없습니다</div>'; return; }
setActive(path);
const chips=[`<span class="chip type">${esc(d.t)}</span>`];
if(d.ver) chips.push('<span class="chip ok">사람 검토됨</span>');
if(d.st==='draft') chips.push('<span class="chip warn">초안 — 요약 전/재검토</span>');
for(const t of (d.tags||[])) chips.push(`<span class="chip">${esc(t)}</span>`);
let h=`<div class="doc-head"><div class="crumb">${esc(path)}</div>
<h2>${esc(d.title||path)}</h2><div class="chips">${chips.join('')}</div>
<div class="gen">생성: <span class="mono">${esc(d.gen||'?')}</span>${d.res?` · <span class="mono">${esc(d.res)}</span>`:''}</div></div>
<article class="md">${renderMd(d.body)}</article>`;
view.innerHTML=h; view.scrollIntoView({block:'start'});
}
/* ---------- 사이드바 ---------- */
function navBtn(cls,doc,inner){ return `<button class="${cls}" data-doc="${esc(doc)}">${inner}</button>`; }
function buildTree(){
let h='<div class="sec">프로그램</div>';
for(const p of D.programs){
/* ▶ 는 unit·조각 목록 펼치기, 이름은 프로그램 문서(인덱싱 결과) 열기 */
h+=`<div class="prog-nav">
<button class="tw-btn" data-toggle="${esc(p.name)}" aria-expanded="false" title="unit · 조각 펼치기"><span class="tw" id="tw-${esc(p.name)}">▶</span></button>
<button class="nav-name" data-doc="${esc(p.path)}" data-expand="${esc(p.name)}" title="${esc(p.title)}">${esc(p.name)}</button>
<span class="cnt" title="로직 조각 수">${p.chunks||0}</span></div>
<div class="units" id="u-${esc(p.name)}" hidden></div>`;
}
if(D.packages.length){ h+='<div class="sec">패키지</div>';
for(const k of D.packages) h+=navBtn('nav-item',k.path,`<span>${esc(k.name)}</span><span class="cnt">${k.count}</span>`); }
if(D.concepts.length){ h+='<div class="sec">개념</div>';
for(const c of D.concepts) h+=navBtn('nav-item',c.path,`<span>${esc(c.name)}</span>`); }
/* 테이블·펑션은 수백 건이라 접어 둔다 — 펼치면 프로그램 목록을 밀어내기 때문 */
h+=entitySection('tables','테이블',D.tables,12);
h+=entitySection('functions','펑션',D.functions,11.5);
tree.innerHTML=h;
}
function entitySection(key,label,items,fs){
if(!items||!items.length) return '';
return `<button class="sec sec-toggle" data-esec="${key}" aria-expanded="false">
<span class="tw" id="tw-e-${key}">▶</span>${label}<span class="cnt">${items.length}</span></button>
<div class="units" id="e-${key}" hidden data-fs="${fs}"></div>`;
}
function fillEntities(key){
const box=document.getElementById('e-'+key); if(box.dataset.filled) return;
const items=D[key]||[]; const fs=box.dataset.fs;
box.innerHTML=items.map(t=>
`<button class="unit-item" data-doc="${esc(t.path)}"><span class="nm" style="font-size:${fs}px">${esc(t.name)}</span></button>`
).join(''); box.dataset.filled='1';
}
/* 프로그램을 펼치면 그 프로그램의 unit·조각 목록을 보여준다.
조각은 별 문서가 아니라 프로그램 문서 안의 섹션이므로, 클릭 시 해당 섹션으로 스크롤한다. */
function fillUnits(name){
const box=document.getElementById('u-'+name); if(box.dataset.filled) return;
const p=D.programs.find(x=>x.name===name); let h='';
if(!p.units.length) h='<div class="unit-empty">조각 없음 — 추출 전</div>';
for(const u of p.units){
h+=`<button class="unit-item" data-doc="${esc(p.path)}" data-jump="${esc(u.name)}">
<span class="utype">${esc(u.type)}</span><span class="nm">${esc(u.name)}</span>
<span class="cnt">${u.chunks.length}</span></button>`;
for(const c of u.chunks)
h+=`<button class="chunk-item" data-doc="${esc(p.path)}" data-jump="${esc(c.purpose)}">
<span class="kind k-${esc(c.kind)}">${esc(c.kind)}</span>
<span class="cp">${esc(c.purpose)}</span></button>`;
}
box.innerHTML=h; box.dataset.filled='1';
}
function setActive(path){
for(const el of document.querySelectorAll('.nav-item.active,.unit-item.active,.nav-name.active')) el.classList.remove('active');
if(!path) return;
const btn=[...tree.querySelectorAll('[data-doc]')].find(b=>b.dataset.doc===path);
if(btn){
btn.classList.add('active');
const box=btn.closest('.units');
if(box){ box.hidden=false; const tw=document.getElementById('tw-'+box.id.slice(2)); if(tw) tw.classList.add('open'); }
}
}
/* ---------- 검색 ---------- */
const idx = Object.entries(D.docs).map(([p,d])=>({p, s:(d.title+' '+(d.d||'')+' '+(d.tags||[]).join(' ')).toLowerCase()}));
document.getElementById('q').addEventListener('input', e=>{
const q=e.target.value.trim().toLowerCase();
if(!q){ buildTree(); return; }
const hits=idx.filter(x=>x.s.includes(q)).slice(0,60);
let h=`<div class="sec">검색 결과 ${hits.length}${hits.length===60?'+':''}</div><div class="results">`;
for(const {p} of hits){ const d=D.docs[p];
h+=navBtn('nav-item',p,`<span><span style="display:block">${esc(d.title)}</span><span class="meta">${esc(d.t)} · ${esc(p)}</span></span>`); }
tree.innerHTML=h+'</div>';
});
/* ---------- 이벤트 ---------- */
/* 문서 안 앵커로 이동 — 뒤로 돌아올 수 있게 브라우저 히스토리에도 남긴다 */
function jumpTo(id){
const el=document.getElementById(id);
if(!el) return;
el.scrollIntoView({block:'start', behavior:'smooth'});
el.classList.add('flash'); setTimeout(()=>el.classList.remove('flash'), 1400);
history.replaceState(null, '', '#'+id);
}
document.addEventListener('click', e=>{
const j=e.target.closest('[data-jump-id]');
if(j){ e.preventDefault(); jumpTo(j.dataset.jumpId); return; }
const t=e.target.closest('[data-toggle]');
if(t){ const n=t.dataset.toggle; fillUnits(n);
const box=document.getElementById('u-'+n); box.hidden=!box.hidden;
t.setAttribute('aria-expanded', String(!box.hidden));
document.getElementById('tw-'+n).classList.toggle('open', !box.hidden); return; }
const es=e.target.closest('[data-esec]');
if(es){ const k=es.dataset.esec; fillEntities(k);
const box=document.getElementById('e-'+k); box.hidden=!box.hidden;
es.setAttribute('aria-expanded', String(!box.hidden));
document.getElementById('tw-e-'+k).classList.toggle('open', !box.hidden); return; }
const b=e.target.closest('[data-doc]');
if(b){
e.preventDefault();
showDoc(b.dataset.doc);
/* 사이드바에서 프로그램 이름을 눌렀으면 그 프로그램의 unit·조각 목록도 펼쳐 둔다 */
const ex=b.dataset.expand;
if(ex){ fillUnits(ex); const box=document.getElementById('u-'+ex);
if(box){ box.hidden=false; document.getElementById('tw-'+ex).classList.add('open');
const tb=tree.querySelector(`[data-toggle="${CSS.escape(ex)}"]`); if(tb) tb.setAttribute('aria-expanded','true'); } }
/* 조각은 별 문서가 아니라 프로그램 문서 안의 섹션이다 — 해당 제목으로 스크롤한다 */
const jump=b.dataset.jump;
if(jump){
const want=jump.trim().toLowerCase();
const hs=[...view.querySelectorAll('h3,h4')];
const hit=hs.find(h=>h.textContent.trim().toLowerCase().includes(want));
if(hit){ hit.scrollIntoView({block:'start'}); hit.classList.add('flash');
setTimeout(()=>hit.classList.remove('flash'),1400); }
}
}
});
document.getElementById('homeBtn').addEventListener('click', showHome);
document.getElementById('homeTitle').addEventListener('click', ()=>{ showHome(); window.scrollTo({top:0}); });
buildTree(); showHome();
</script>