- 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>
496 lines
20 KiB
Python
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()
|