Files
Aislo/docs/tools/build.py
T

458 lines
18 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": "structure",
"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)
def typedoc_pages(out: Path) -> dict[tuple[str, str], str]:
project = json.loads((out / "typedoc.json").read_text(encoding="utf-8"))
modules = [m for m in project.get("children", []) if m["kind"] == 2] or [
{**project, "name": ""}
]
pages = {}
for mod in modules:
base = f"ts/{mod['name']}/" if mod["name"] else "ts/"
for c in mod.get("children", []):
if c["kind"] in (64, 32) and c.get("sources"):
rel = f"{base}{c['name']}.html"
if (out / "src" / rel).exists():
pages[(c["sources"][0]["fileName"], c["name"])] = rel
return pages
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:
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 = (
[{"TypeScript": "ts/index.html"}] if (self.src / "ts" / "index.html").exists() else []
)
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()