Files
Aislo/docs/tools/build.py
T
eomsangdonandClaude Opus 5.5 47a63d3fa4 feat(478-2 문서 사이트): 진짜 추출로 맞춤 — TypeDoc kind-dir(대소문자 겹침 막음) · 도구 대 추출 비교 규칙 · 공용 도구 묶음 · 손글 지문 · TS 메뉴 쪽
- TypeDoc router kind-dir — structure 는 같은 이름 인터페이스 · 함수가 Windows 에서 한 파일로 덮임 · 겹침 링크 안 함
- 비교: TS = export 함수 · 메서드 · 생성자 · 접근자(상속 · 숨은 생성자 뺌 · export * 는 원본으로) · 파이썬 = 안쪽 함수 뺀 전부 ↔ griffe(속성 · 자동 생성 __init__ 반영)
- groups.yml 공용 도구(common_util_* 끝 규칙) · 묶음 없음 0 · 손글 18 묶음 지문
- 결과: 함수 5,746 · 묶음 18 · 세션 칸 45 · 서버 주소 41 · strict 경고 0 · test_478_docs 9 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:22:10 +09:00

496 lines
20 KiB
Python

"""문서 사이트 원고 만들기(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']}",
"",
'<div class="grid cards" markdown>',
"",
*cards,
"</div>",
"",
]
md += [f"묶음 없음 함수 {len(none)} — [목록](none.md)", ""]
if graph := self.group_graph(page, groups):
md += ["## 묶음 사이 흐름", "", graph, ""]
md.append(self.hand_refs(body, "page.md"))
(self.src / page["id"] / "index.md").write_text("\n".join(md) + "\n", encoding="utf-8")
return nav
def group_graph(self, page: dict, groups: dict[str, list[dict]]) -> str:
edges = Counter(
(self.gid[f["id"]], self.gid[c])
for fns in groups.values()
for f in fns
for c in f.get("calls", [])
if c in self.gid and self.gid[c] != self.gid[f["id"]]
)
if not edges:
return ""
titles = {g["id"]: g["title"] for g in page["groups"]} | {grouping.NONE: "묶음 없음"}
used = sorted({a for e in edges for a in e})
lines = ["```mermaid", "flowchart LR"] + [f' {g}["{titles[g]}"]' for g in used]
lines += [f" {a} -->|{n}| {b}" for (a, b), n in sorted(edges.items())]
return "\n".join(lines + ["```"])
def build(self, typedoc: bool = True) -> dict:
if self.src.exists():
shutil.rmtree(self.src)
(self.src / "py").mkdir(parents=True)
files = sorted(self.by_file)
if typedoc:
self.ts_pages = run_typedoc([f for f in files if f.endswith(".ts")], self.out)
self.py_refs = write_py_pages([f for f in files if f.endswith(".py")], self.src)
nav = [{"처음": "index.md"}]
home = ["# Aislo 프로그램 문서", "", "페이지 → 로직 묶음 → 함수 차례로 눌러 내려감.", ""]
for page in self.rules["pages"]:
groups = grouping.assign(self.fns, self.data["registry"], page["groups"])
self.gid.update({f["id"]: gid for gid, fns in groups.items() for f in fns})
nav.append({page["title"]: self.write_page(page, groups)})
home.append(f"- [{page['title']}]({page['id']}/index.md)")
ts_nav = []
if (self.src / "ts" / "index.html").exists():
(self.src / "ts.md").write_text(
"# TypeScript 함수 화면\n\n[TypeDoc 열기](ts/index.html)\n", encoding="utf-8"
)
ts_nav = [{"TypeScript": "ts.md"}]
nav.append({"함수 화면": [*ts_nav, {"파이썬": "py/index.md"}]})
(self.src / "index.md").write_text("\n".join(home) + "\n", encoding="utf-8")
(self.src / ".nav.json").write_text(json.dumps(nav, ensure_ascii=False), encoding="utf-8")
return self.report()
def report(self) -> dict:
exported = [f for f in self.fns if f.get("exported")]
return {
"functions": len(self.fns),
"groups": sum(len(p["groups"]) for p in self.rules["pages"]),
"ungrouped": sum(1 for v in self.gid.values() if v == grouping.NONE),
"session_keys": len(self.data["session_keys"]),
"api": len(self.data["api"]),
"undocumented_exports": sorted(
f["id"] for f in exported if not first_line(f.get("doc"))
),
"missing_refs": self.missing_refs,
"stale_groups": self.stale,
}
def build_site(out: Path, strict: bool = True) -> None:
"""mkdocs build(--strict) — docs_dir = out/src · site_dir = out/site."""
from mkdocs.commands.build import build
from mkdocs.config import load_config
cfg = load_config(
str(DOCS / "mkdocs.yml"),
docs_dir=str(out / "src"),
site_dir=str(out / "site"),
strict=strict,
)
build(cfg)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--data", type=Path, default=DOCS / "_data")
ap.add_argument("--out", type=Path, default=DOCS / "_build")
ap.add_argument("--site", action="store_true", help="mkdocs build --strict 까지")
ap.add_argument("--stamp", action="store_true", help="지금 함수 목록 지문을 손글에 적음")
args = ap.parse_args()
t0 = time.time()
data, rules = load_data(args.data), grouping.load_rules()
if args.stamp:
for page in rules["pages"]:
groups = grouping.assign(data["functions"], data["registry"], page["groups"])
for g in page["groups"]:
path = DOCS / page["guide"] / "groups" / f"{g['id']}.md"
if path.exists():
stamp(path, grouping.fingerprint(groups[g["id"]]))
print("지문 적음")
return
args.out.mkdir(parents=True, exist_ok=True)
out = args.out.resolve()
rep = Site(data, rules, out).build()
if args.site:
build_site(out)
summary = {k: (len(v) if isinstance(v, list) else v) for k, v in rep.items()}
print(json.dumps(summary, ensure_ascii=False), f"{time.time() - t0:.1f}s")
for ref in rep["missing_refs"]:
print("손글 함수 없음:", ref)
if __name__ == "__main__":
main()