"""문서 사이트 원고 만들기(PLAN 478-2). 추출(docs/_data/*.json) + 손글(docs/guide/<페이지>/page.md · groups/<묶음>.md) → docs/_build/src index.md 처음 <페이지>/index.md 개요 · 묶음 상자 · 묶음 사이 흐름 그림 · 손글(규칙 · 사용자 결정) <페이지>/<묶음>.md 손글 설명 · 묶음 안 흐름 그림 · 세션 칸 · 서버 주소 · 함수 칸 <페이지>/none.md 묶음 없음 ts/ · py/ 함수 화면 — TypeDoc(HTML) · mkdocstrings(griffe)가 만듦 .nav.json 왼쪽 메뉴(nav_hook.py 가 읽음) 함수 목록 지문이 손글 지문과 다른 묶음 = 「설명 확인 필요」 · 맞으면 --stamp. venv python docs/tools/build.py [--data D] [--out O] [--site] [--stamp] """ from __future__ import annotations import argparse import hashlib import json import re import shutil import subprocess import sys import time from collections import Counter from pathlib import Path import griffe import yaml sys.path.insert(0, str(Path(__file__).resolve().parent)) import group as grouping # noqa: E402 TOOLS = grouping.TOOLS DOCS = TOOLS.parent ROOT = DOCS.parent REF = re.compile(r"\[\[([^\]]+)\]\]") MAX_GRAPH = 40 # ── 읽기 ──────────────────────────────────────────────────────────────────── def load_data(data: Path) -> dict: out = {} for name in ("functions", "session_keys", "api", "registry"): path = data / f"{name}.json" out[name] = json.loads(path.read_text(encoding="utf-8")) if path.exists() else [] if not out["functions"]: raise SystemExit(f"추출 없음: {data / 'functions.json'}") return out def read_hand(path: Path) -> tuple[dict, str]: if not path.exists(): return {}, "" text = path.read_text(encoding="utf-8") if text.startswith("---\n"): head, _, body = text[4:].partition("\n---\n") return yaml.safe_load(head) or {}, body.strip() return {}, text.strip() def stamp(path: Path, print_: str) -> None: meta, body = read_hand(path) meta["지문"] = print_ head = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip() path.write_text(f"---\n{head}\n---\n\n{body}\n", encoding="utf-8") # ── 함수 화면(기성 도구) ─────────────────────────────────────────────────── def run_typedoc(ts_files: list[str], out: Path) -> dict[tuple[str, str], str]: """TypeDoc HTML 로 ts/ 함수 화면 · (파일, 이름) → 원고 안 경로.""" if not ts_files: return {} opts = { "entryPoints": [(ROOT / f).as_posix() for f in ts_files], "tsconfig": (ROOT / "tsconfig.json").as_posix(), "skipErrorChecking": True, "router": "kind-dir", "out": (out / "src" / "ts").as_posix(), "json": (out / "typedoc.json").as_posix(), "readme": "none", "logLevel": "Error", } opt_file = out / "typedoc.options.json" opt_file.write_text(json.dumps(opts, ensure_ascii=False), encoding="utf-8") subprocess.run( ["node", str(TOOLS / "node_modules/typedoc/bin/typedoc"), "--options", str(opt_file)], cwd=ROOT, check=True, ) return typedoc_pages(out) FN_KINDS = {64, 512, 2048, 262144} # TypeDoc 함수 · 생성자 · 메서드 · 접근자 KIND_DIR = {64: "functions", 32: "variables", 128: "classes"} # router kind-dir 폴더 def typedoc_entries(out: Path) -> list[tuple[str, str, int, str]]: """TypeDoc JSON → (파일, 이름, 종류, 화면 경로). 클래스 칸 = `Class.name` · 다시 내보내기 = 가리키는 것의 종류. """ project = json.loads((out / "typedoc.json").read_text(encoding="utf-8")) modules = [m for m in project.get("children", []) if m["kind"] == 2] by_id: dict[int, dict] = {} def index(r: dict) -> None: by_id[r["id"]] = r for c in r.get("children", []): index(c) index(project) rows = [] for mod in modules: file = mod["sources"][0]["fileName"] if mod.get("sources") else f"{mod['name']}.ts" slug = mod["name"].replace("/", "_") for c in mod.get("children", []): folder = KIND_DIR.get(c["kind"]) page = f"ts/{folder}/{slug}.{c['name']}/index.html" if folder else "" kind = c["kind"] if kind == 4194304: kind = by_id.get(c.get("target"), {}).get("kind", kind) rows.append((file, c["name"], kind, page)) if c["kind"] != 128: continue for m in c.get("children", []): if m.get("sources") and not m.get("flags", {}).get("isInherited"): rows.append( (file, f"{c['name']}.{m['name']}", m["kind"], f"{page}#{m['name'].lower()}") ) return rows def typedoc_pages(out: Path) -> dict[tuple[str, str], str]: """(파일, 이름) → 화면 경로 · 대소문자만 다른 겹침(Windows 한 파일)은 링크 안 함.""" rows = [r for r in typedoc_entries(out) if r[3]] seen = Counter(rel.split("#")[0].lower() for *_, rel in rows if "#" not in rel) return { (file, name): rel for file, name, _, rel in rows if seen[rel.split("#")[0].lower()] == 1 and (out / "src" / rel.split("#")[0]).exists() } def py_ident(file: str) -> str: return file.removesuffix(".py").replace("/", ".") def griffe_names(file: str) -> set[str]: """griffe 가 보는 함수 이름(모듈 함수 · 클래스 메서드 · 속성 `Class.name` · 자동 생성 뺌).""" mod = griffe.load(py_ident(file), search_paths=[str(ROOT)], allow_inspection=False) names: set[str] = set() def walk(obj, prefix: str) -> None: for name, m in obj.members.items(): if m.is_alias: continue if (m.is_function or "property" in m.labels) and m.lineno: names.add(prefix + name) elif m.is_class: walk(m, f"{prefix}{name}.") walk(mod, "") return names def write_py_pages(py_files: list[str], src: Path) -> dict[tuple[str, str], str]: """mkdocstrings 원고 · (파일, 이름) → autorefs 식별자.""" refs = {} lines = ["# 파이썬 함수 화면", ""] for file in py_files: ident = py_ident(file) (src / "py" / f"{ident}.md").write_text(f"# {ident}\n\n::: {ident}\n", encoding="utf-8") lines.append(f"- [{file}]({ident}.md)") refs.update({(file, n): f"{ident}.{n}" for n in griffe_names(file)}) (src / "py" / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8") return refs # ── 원고 ─────────────────────────────────────────────────────────────────── def anchor(fid: str) -> str: return "f-" + hashlib.sha1(fid.encode("utf-8")).hexdigest()[:10] def first_line(doc: str) -> str: return next((s.strip() for s in (doc or "").splitlines() if s.strip()), "") def owner_of(item: str, fns_by_id: dict, by_file: dict) -> dict | None: """세션 칸 · 서버 주소의 `id` · `파일:줄` · `파일` → 함수.""" if item in fns_by_id: return fns_by_id[item] if item[:3] in ("ts:", "py:"): item = item[3:] file, _, line = item.split("#")[0].partition(":") cands = by_file.get(file, []) if not cands: return None if line.isdigit(): before = [f for f in cands if f.get("line", 0) <= int(line)] return before[-1] if before else cands[0] return cands[0] class Site: def __init__(self, data: dict, rules: dict, out: Path): self.data, self.rules, self.out = data, rules, out.resolve() self.src = self.out / "src" self.fns = data["functions"] self.by_id = {f["id"]: f for f in self.fns} self.by_file: dict[str, list] = {} for f in sorted(self.fns, key=lambda f: f.get("line", 0)): self.by_file.setdefault(f["file"], []).append(f) self.gid: dict[str, str] = {} self.missing_refs: list[str] = [] self.stale: list[str] = [] self.ts_pages: dict = {} self.py_refs: dict = {} # 링크 def fn_link(self, fid: str, label: str | None = None) -> str: f = self.by_id.get(fid) if f is None: return f"`{label or fid.split('#')[-1]}`" return f"[`{label or f['name']}`]({self.gid[fid]}.md#{anchor(fid)})" def hand_refs(self, text: str, where: str) -> str: def sub(m: re.Match) -> str: ref = m.group(1).strip() hit = [ f for f in self.fns if f["name"] == ref or f"{grouping.stem(f['file'])}#{f['name']}" == ref ] if not hit: self.missing_refs.append(f"{where}: {ref}") return f"`{ref}` ⚠" return self.fn_link(hit[0]["id"]) return REF.sub(sub, text) def tool_link(self, f: dict) -> str: if f["lang"] == "ts": rel = self.ts_pages.get((f["file"], f["name"])) return f"[자세히](../{rel})" if rel else "" ref = self.py_refs.get((f["file"], f["name"])) return f"[자세히][{ref}]" if ref else "" # 함수 칸 def fn_block(self, f: dict) -> str: params = ", ".join( p["name"] if isinstance(p, dict) else str(p) for p in f.get("params", []) ) bits = [f"`{f['file']}:{f.get('line', 0)}`"] if params: bits.append(f"입력 `{params}`") if f.get("returns"): bits.append(f"출력 `{f['returns']}`") if link := self.tool_link(f): bits.append(link) lines = [ f"### `{f['name']}` {{ #{anchor(f['id'])} }}", "", first_line(f.get("doc")) or "*설명 없음*", "", ] lines += [" · ".join(bits), ""] callers = f.get("called_by", []) if callers: shown = " · ".join(self.fn_link(c) for c in callers[:8]) more = f" 외 {len(callers) - 8}" if len(callers) > 8 else "" lines += [f"부르는 곳: {shown}{more}", ""] return "\n".join(lines) def fn_graph(self, fns: list[dict]) -> str: ids = {f["id"] for f in fns} edges = [(f["id"], c) for f in fns for c in f.get("calls", []) if c in ids and c != f["id"]] if not edges: return "" degree = Counter(a for e in edges for a in e) keep = {i for i, _ in degree.most_common(MAX_GRAPH)} node = {i: f"n{k}" for k, i in enumerate(sorted(keep))} lines = ["```mermaid", "flowchart LR"] lines += [f' {node[i]}["{self.by_id[i]["name"]}"]' for i in sorted(keep)] lines += [ f" {node[a]} --> {node[b]}" for a, b in sorted(set(edges)) if a in keep and b in keep ] lines.append("```") note = f"\n\n많이 이어진 함수 {MAX_GRAPH} 개만 그림." if len(degree) > MAX_GRAPH else "" return "\n".join(lines) + note def group_uses(self, gid: str) -> tuple[list[str], list[str]]: def mine(item: str) -> bool: o = owner_of(item, self.by_id, self.by_file) return o is not None and self.gid[o["id"]] == gid keys = [] for k in self.data["session_keys"]: reads, writes = ( [r for r in k.get("reads", []) if mine(r)], [w for w in k.get("writes", []) if mine(w)], ) if reads or writes: keys.append(f"| `{k['key']}` | {len(reads)} | {len(writes)} |") apis = [] for a in self.data["api"]: calls = [c for c in a.get("callers", []) if mine(c)] if mine(a.get("handler", "")) or calls: handler = ( self.fn_link(a["handler"]) if a.get("handler") in self.by_id else f"`{a.get('handler', '')}`" ) apis.append(f"| `{a['method']} {a['path']}` | {handler} | {len(calls)} |") return keys, apis # 화면 def write_group(self, page: dict, g: dict, fns: list[dict], hand_dir: Path) -> str: meta, body = read_hand(hand_dir / f"{g['id']}.md") now = grouping.fingerprint(fns) out = [f"# {g['title']}", ""] if meta.get("지문") != now: self.stale.append(g["id"]) out += [ '!!! warning "설명 확인 필요"', f" 함수 목록이 바뀜(손글 지문 `{meta.get('지문', '없음')}` · 지금 `{now}`).", "", ] out += [self.hand_refs(body, f"groups/{g['id']}.md") or "*설명 없음*", ""] if graph := self.fn_graph(fns): out += ["## 묶음 안 흐름", "", graph, ""] keys, apis = self.group_uses(g["id"]) if keys: out += ["## 쓰는 세션 칸", "", "| 칸 | 읽는 곳 | 쓰는 곳 |", "|---|---|---|", *keys, ""] if apis: out += [ "## 서버 주소", "", "| 주소 | 받는 함수 | 부르는 곳 |", "|---|---|---|", *apis, "", ] out += [f"## 함수 {len(fns)}", "", *(self.fn_block(f) for f in fns)] (self.src / page["id"] / f"{g['id']}.md").write_text("\n".join(out), encoding="utf-8") return first_line(body) def write_page(self, page: dict, groups: dict[str, list[dict]]) -> list: hand_dir = DOCS / page["guide"] (self.src / page["id"]).mkdir(parents=True, exist_ok=True) cards, nav = [], [{"개요": f"{page['id']}/index.md"}] for g in page["groups"]: fns = groups[g["id"]] lead = self.write_group(page, g, fns, hand_dir / "groups") warn = " · ⚠ 설명 확인 필요" if g["id"] in self.stale else "" cards += [ f"- **[{g['title']}]({g['id']}.md)** · 함수 {len(fns)}{warn}", "", f" {lead}", "", ] nav.append({g["title"]: f"{page['id']}/{g['id']}.md"}) none = groups[grouping.NONE] none_md = [f"# 묶음 없음 {len(none)}", "", "groups.yml 규칙에 안 맞는 함수.", ""] (self.src / page["id"] / "none.md").write_text( "\n".join(none_md + [self.fn_block(f) for f in none]), encoding="utf-8" ) nav.append({"묶음 없음": f"{page['id']}/none.md"}) _, body = read_hand(hand_dir / "page.md") md = [ f"# {page['title']}", "", '