"""위키 뷰어 빌더 — wiki/ 를 스캔해 단일 HTML(뷰어)로 만든다. - 템플릿: wiki_out/viewer.html (플레이스홀더 __DATA_JSON__, __GENERATED_AT__) - 빌드: python -m wiki_out.viewer [--out PATH] (기본 data/wiki-viewer.html) - 라이브: query/api.py 의 GET /wiki-viewer 가 요청마다 build_html() 호출 - 공유용 스냅샷은 이 출력을 Claude 아티팩트로 게시 (index.db 관측소와 같은 패턴) """ from __future__ import annotations import argparse import json import re from datetime import datetime from pathlib import Path from config.settings import settings from .merge import is_human_verified, split_frontmatter TEMPLATE = Path(__file__).parent / "viewer.html" def _fm_get(fm: str, key: str) -> str | None: m = re.search(rf"^{re.escape(key)}: (.*)$", fm, re.M) return m.group(1).strip() if m else None def _unquote(v: str | None) -> str: if not v: return "" if v.startswith('"'): try: return json.loads(v) except ValueError: return v.strip('"') return v def _list(v: str | None) -> list: if not v or not v.startswith("["): return [] try: return json.loads(v) except ValueError: return [] # 프로그램 문서의 조각 섹션을 읽어 사이드바 내비게이션을 만든다. # okf_writer 가 생성하는 모양에 의존한다: # ### FORM SELECT_DATA — `ZFIR0030F01` L64-L80 (17줄) # #### [sql_select] 미처리 건을 읽는다 _UNIT_H = re.compile(r"^### (\w+) (.+?) — `([^`]+)` L(\d+)-L(\d+)", re.M) _CHUNK_H = re.compile(r"^#### \[(\w+)\] (.+)$", re.M) def _chunk_nav(body: str) -> list[dict]: """프로그램 문서 본문 → [{unit, unit_type, include, kind, purpose}] (문서 순서 유지). 구조 변경(2026-09-16)으로 units/ 문서가 없어졌으므로, 내비게이션은 프로그램 문서 안의 조각 섹션에서 뽑는다. 예전 x-unit-* frontmatter 기반 코드는 항상 빈 목록을 돌려줬다. """ out: list[dict] = [] cur = {"unit": "", "unit_type": "", "include": ""} for m in re.finditer(r"^(###|####) (.*)$", body, re.M): level, text = m.group(1), m.group(2) if level == "###": um = _UNIT_H.match(m.group(0)) if um: cur = {"unit": um.group(2), "unit_type": um.group(1), "include": um.group(3)} else: cur = {"unit": text, "unit_type": "", "include": ""} else: cm = _CHUNK_H.match(m.group(0)) if cm: out.append({**cur, "kind": cm.group(1), "purpose": cm.group(2)}) return out def build_data(wiki_dir: Path | None = None) -> dict: wiki_dir = wiki_dir or settings.wiki_dir docs: dict[str, dict] = {} chunk_nav: dict[str, list[dict]] = {} # program → 조각 목록 for path in sorted(wiki_dir.rglob("*.md")): rel = path.relative_to(wiki_dir).as_posix() if path.name in ("index.md", "log.md", "_review.md"): continue fm, body = split_frontmatter(path.read_text(encoding="utf-8")) if fm is None: continue gen = "" m = re.search(r'generated:.*?by:\s*"([^"]+)".*?at:\s*"([^"]+)"', fm) if m: gen = f"{m.group(1)} · {m.group(2)}" doc = { "t": _fm_get(fm, "type") or "?", "title": _unquote(_fm_get(fm, "title")) or rel, "d": _unquote(_fm_get(fm, "description")), "tags": _list(_fm_get(fm, "tags")), "st": _fm_get(fm, "status") or "stable", "gen": gen, "res": _unquote(_fm_get(fm, "resource")), "ver": is_human_verified(fm), "body": body.strip(), } docs[rel] = doc if doc["t"] == "abap-program": name = rel.rsplit("/", 1)[-1][:-3] chunk_nav[name] = _chunk_nav(doc["body"]) doc["chunk_count"] = len(chunk_nav[name]) programs = [] for rel, d in docs.items(): if d["t"] != "abap-program": continue name = rel.rsplit("/", 1)[-1][:-3] chunks = chunk_nav.get(name, []) # unit 단위로 묶어 사이드바에 접히는 목록으로 준다 by_unit: list[dict] = [] for c in chunks: if not by_unit or by_unit[-1]["name"] != c["unit"]: by_unit.append({"name": c["unit"], "type": c["unit_type"], "include": c["include"], "chunks": []}) by_unit[-1]["chunks"].append({"kind": c["kind"], "purpose": c["purpose"]}) # 개요 표에는 짧은 타이틀(frontmatter title 의 "NAME — " 뒤)만 싣고, 긴 LLM 설명은 desc 로 따로 준다 short = re.sub(rf"^{re.escape(name)}\s*[—-]\s*", "", d["title"]).strip() or name programs.append({ "name": name, "path": rel, "title": short, "desc": d["d"] or "", "status": d["st"], "verified": d["ver"], "units": by_unit, "chunks": len(chunks), "done": len(by_unit), "total": len(by_unit), }) programs.sort(key=lambda p: p["name"]) packages = sorted( ({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel, "count": len([1 for ln in d["body"].splitlines() if ln.startswith("| [")])} for rel, d in docs.items() if d["t"] == "abap-package"), key=lambda p: p["name"]) concepts = sorted( ({"name": d["title"], "path": rel} for rel, d in docs.items() if d["t"] == "concept"), key=lambda c: c["name"]) review_path = wiki_dir / "_review.md" review = (sum(1 for ln in review_path.read_text(encoding="utf-8").splitlines() if ln.startswith("- [ ]")) if review_path.exists() else 0) tables = sorted( ({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel} for rel, d in docs.items() if d["t"] == "abap-table"), key=lambda c: c["name"]) functions = sorted( ({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel} for rel, d in docs.items() if d["t"] == "abap-function"), key=lambda c: c["name"]) n_chunks = sum(p["chunks"] for p in programs) units_total = sum(p["total"] for p in programs) return { "stats": { "programs": len(programs), "chunks": n_chunks, "units_total": units_total, "units_done": units_total, "tables": len(tables), "functions": len(functions), "human_verified": sum(1 for d in docs.values() if d["ver"]), "review": review, }, "docs": docs, "programs": programs, "packages": packages, "concepts": concepts, "tables": tables, "functions": functions, } def build_html(wiki_dir: Path | None = None) -> str: data = build_data(wiki_dir) payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace(" None: ap = argparse.ArgumentParser(description="위키 뷰어 HTML 빌드") ap.add_argument("--out", default=None) args = ap.parse_args() out = Path(args.out) if args.out else settings.wiki_dir.parent / "data" / "wiki-viewer.html" out.parent.mkdir(parents=True, exist_ok=True) html = build_html() out.write_text(html, encoding="utf-8") print(f"{out} ({len(html) / 1024:.0f} KB)") if __name__ == "__main__": main()