455 lines
21 KiB
Python
455 lines
21 KiB
Python
"""
|
|
The UI framework's reference, generated from its source into spr's docs site.
|
|
|
|
python3 -m common.slice reference (from soleprint/; or make reference in common/ui)
|
|
make docs serves it, with the rest of spr's docs
|
|
|
|
It is a page of the `/docs` minisite (`docs/data/en/ui-reference.md`), because in
|
|
soleprint `make docs` always means one thing: launch that site. A component does
|
|
not grow a second documentation tree of its own.
|
|
|
|
The same bargain docgen makes, done here without docgen — docgen is a copy-out
|
|
tool with its own release cycle, and a library's reference should not wait on it:
|
|
|
|
**The source is read, never described.** Every prop, model, emit, slot, file,
|
|
package and token in the reference comes from graph.py and surface.py reading
|
|
common/ui/src. A prop that is removed leaves the reference on the next build.
|
|
|
|
**One format in the middle.** `inventory.json` is the only thing extraction
|
|
produces; the reference is rendered from it and nothing else. It has docgen's
|
|
three keys (`meta`, `nodes`, `edges`), stable ids, a closed `kind` vocabulary per
|
|
node, and no visual information at all — so the web view that comes later, or
|
|
docgen itself, reads the same file instead of re-reading the source.
|
|
|
|
**Both ends are measured.** The larder says what came in — files seen, read, and
|
|
failed **by name**; the book says what came out, and is reconciled against it.
|
|
"33 files in, 34 exports documented, nothing lost" is a fact. A clean reference
|
|
over an incomplete read is a lie by omission.
|
|
|
|
**Generated base, hand-written notes.** Prose only a person can write — why a
|
|
thing exists, what replaced what, who actually uses it — lives in
|
|
`common/ui/notes/<spot>.md`, beside the library, and is placed above its spot on every build. A note whose
|
|
spot is gone is reported and fails the check; it is never dropped.
|
|
|
|
**A rebuild replaces exactly what the last one wrote.** `docs/.generated.json`
|
|
records every generated file and its hash. A generated file edited by hand is
|
|
refused, not overwritten, until `--force` — the same rule a slice's dest keeps.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from ..theme import bake
|
|
from . import graph, surface, tokens
|
|
|
|
SITE = graph.COMMON.parent.parent / "docs" # spr's /docs minisite
|
|
PAGE = "data/en/ui-reference.md" # its id in topics.json: ui-reference
|
|
INVENTORY = "data/ui-inventory.json"
|
|
MANIFEST = "data/.ui-reference.generated.json"
|
|
NOTES = graph.COMMON / "ui" / "notes"
|
|
SCHEMA_VERSION = "1"
|
|
|
|
# The groups the library is laid out in, and what a file in each one is. A file
|
|
# at the top of src/ is the library's own plumbing: barrel and stylesheets.
|
|
KINDS = {
|
|
"components": "component",
|
|
"renderers": "renderer",
|
|
"datasources": "datasource",
|
|
"composables": "composable",
|
|
"plugins": "plugin",
|
|
}
|
|
ORDER = list(KINDS) + [""]
|
|
|
|
STUB = "<!-- note: {spot} — write below this line, or replace it; a stub renders nothing -->\n"
|
|
SPOTS_FIXED = ("_intro", "larder", "contents", "types", "tokens", "book", "_end")
|
|
|
|
|
|
# ── extraction → inventory ────────────────────────────────────────────────────
|
|
|
|
def _group(rel: str) -> str:
|
|
return rel.split("/", 1)[0] if "/" in rel else ""
|
|
|
|
|
|
def _kind(rel: str) -> str:
|
|
if "/" not in rel:
|
|
return "stylesheet" if rel.endswith(".css") else "barrel"
|
|
return KINDS.get(_group(rel), "module")
|
|
|
|
|
|
def _bindings(nodes: dict) -> dict[str, list[str]]:
|
|
"""Library file -> the plain parts derived from it, read from each part's header.
|
|
|
|
A part says where it came from in its own header ("Derived from
|
|
common/ui/src/components/Panel.vue"), so the link is kept by the part,
|
|
not by a table here that would go stale.
|
|
"""
|
|
by_base = {}
|
|
for rel in nodes:
|
|
by_base.setdefault(rel.rsplit("/", 1)[-1], []).append(rel)
|
|
out: dict[str, set] = {}
|
|
for name in bake.load_parts():
|
|
text = ""
|
|
for ext in ("css", "js"):
|
|
p = bake.PARTS / f"{name}.{ext}"
|
|
if p.exists():
|
|
t = p.read_text()
|
|
text += t[: t.find("*/")] if "*/" in t else ""
|
|
found = set(re.findall(r"common/ui/src/([\w./-]+\.(?:vue|ts|css))", text))
|
|
m = re.search(r"Derived from ([^\n]*(?:\n \*[^\n]*){0,2})", text)
|
|
if m:
|
|
for base in re.findall(r"\b([A-Za-z]\w*\.(?:vue|ts|css))\b", m.group(1)):
|
|
if len(by_base.get(base, [])) == 1:
|
|
found.add(by_base[base][0])
|
|
for rel in found:
|
|
if rel in nodes:
|
|
out.setdefault(rel, set()).add(name)
|
|
return {k: sorted(v) for k, v in out.items()}
|
|
|
|
|
|
def inventory(src: Path = graph.SRC) -> dict:
|
|
"""The one document everything else is rendered from."""
|
|
nodes = graph.load(src)
|
|
exports = graph.barrel(src, nodes)
|
|
surfaces = surface.load(src)
|
|
bindings = _bindings(nodes)
|
|
library = tokens.source(None)
|
|
theme = tokens.source("soleprint")
|
|
|
|
seen = sorted(p.relative_to(src).as_posix() for p in src.rglob("*")
|
|
if p.is_file() and not graph.SKIP & set(p.relative_to(src).parts))
|
|
failed = [{"name": rel, "error": f"no reader for {Path(rel).suffix or 'a file with no suffix'}"}
|
|
for rel in seen if rel not in nodes]
|
|
failed += [{"name": s.file, "error": s.error} for s in surfaces.values() if s.error]
|
|
|
|
out_nodes, edges = [], []
|
|
for rel, n in sorted(nodes.items()):
|
|
s = surfaces.get(rel)
|
|
attrs = {"path": rel, "bytes": n.size, "group": _group(rel)}
|
|
if s and s.summary:
|
|
attrs["summary"] = s.summary
|
|
if s and rel.endswith(".vue"):
|
|
attrs["props"] = [vars(p) for p in s.props]
|
|
attrs["models"] = [vars(m) for m in s.models]
|
|
attrs["emits"] = [vars(e) for e in s.emits]
|
|
attrs["slots"] = list(s.slots)
|
|
if s and s.declarations:
|
|
attrs["declarations"] = [{"kind": k, "name": nm, "doc": d} for k, nm, d in s.declarations]
|
|
if s and s.error:
|
|
attrs["error"] = s.error
|
|
out_nodes.append({"id": f"file:{rel}", "kind": _kind(rel), "label": rel.rsplit("/", 1)[-1],
|
|
"parent": None, "attrs": attrs})
|
|
for target in sorted(n.imports):
|
|
edges.append({"source": f"file:{rel}", "target": f"file:{target}", "kind": "imports", "attrs": {}})
|
|
for pkg in sorted({graph.package_of(p) for p in n.packages}):
|
|
edges.append({"source": f"file:{rel}", "target": f"package:{pkg}", "kind": "requires", "attrs": {}})
|
|
for tok in sorted(n.tokens):
|
|
edges.append({"source": f"file:{rel}", "target": f"token:{tok}", "kind": "reads", "attrs": {}})
|
|
|
|
for e in exports:
|
|
closure, _ = graph.select([e.name], [], exports, nodes)
|
|
out_nodes.append({"id": f"export:{e.name}", "kind": "type" if e.is_type else "export",
|
|
"label": e.name, "parent": f"file:{e.file}",
|
|
"attrs": {"file": e.file, "local": e.local,
|
|
"closure": sorted(closure),
|
|
"packages": graph.dependencies(closure, nodes),
|
|
"tokens": sorted(graph.tokens_read(closure, nodes)),
|
|
"bytes": sum(nodes[f].size for f in closure)}})
|
|
|
|
for pkg, version in sorted(graph.dependencies(set(nodes), nodes).items()):
|
|
out_nodes.append({"id": f"package:{pkg}", "kind": "package", "label": pkg,
|
|
"parent": None, "attrs": {"version": version}})
|
|
|
|
names = set(library.table) | set().union(*(n.tokens for n in nodes.values()))
|
|
for name in sorted(names):
|
|
lib, thm = tokens.resolved(library, name), tokens.resolved(theme, name)
|
|
out_nodes.append({"id": f"token:{name}", "kind": "token", "label": name, "parent": None,
|
|
"attrs": {"library": lib, "theme": thm}})
|
|
|
|
for rel, parts in sorted(bindings.items()):
|
|
for part in parts:
|
|
if not any(n["id"] == f"part:{part}" for n in out_nodes):
|
|
out_nodes.append({"id": f"part:{part}", "kind": "part", "label": part, "parent": None,
|
|
"attrs": {"path": f"common/theme/parts/{part}"}})
|
|
edges.append({"source": f"part:{part}", "target": f"file:{rel}", "kind": "binds", "attrs": {}})
|
|
|
|
larder = {"kind": "vue-library", "identity": "common/ui/src", "unit": "file",
|
|
"seen": len(seen), "failed": failed,
|
|
"extra": {"exports": sum(1 for e in exports if not e.is_type),
|
|
"types": sum(1 for e in exports if e.is_type)}}
|
|
return {"meta": {"source": "common/ui/src", "schema_version": SCHEMA_VERSION,
|
|
"generator": "common.slice docs", "larder": larder},
|
|
"nodes": out_nodes, "edges": edges}
|
|
|
|
|
|
# ── the book measure: what came out, reconciled against what came in ──────────
|
|
|
|
def reconcile(inv: dict, notes: dict[str, str]) -> list[tuple[bool, str]]:
|
|
larder = inv["meta"]["larder"]
|
|
read = larder["seen"] - len([f for f in larder["failed"] if f["error"].startswith("no reader")])
|
|
files = [n for n in inv["nodes"] if n["id"].startswith("file:")]
|
|
values = [n for n in inv["nodes"] if n["kind"] == "export"]
|
|
types = [n for n in inv["nodes"] if n["kind"] == "type"]
|
|
vue = [n for n in files if n["attrs"]["path"].endswith(".vue")]
|
|
unparsed = [n["attrs"]["path"] for n in vue if "error" in n["attrs"]]
|
|
orphans = sorted(set(notes) - set(spots(inv)))
|
|
return [
|
|
(read == len(files), f"{read} file(s) read produced {len(files)} file node(s)"),
|
|
(len(values) == larder["extra"]["exports"] and len(types) == larder["extra"]["types"],
|
|
f"{len(values)} export(s) and {len(types)} type(s) in the barrel, each with a node"),
|
|
(not unparsed, f"{len(vue) - len(unparsed)} of {len(vue)} component(s) have their surface read"
|
|
+ (f" — not: {', '.join(unparsed)}" if unparsed else "")),
|
|
(not orphans, "every note lands on a spot" if not orphans
|
|
else f"{len(orphans)} note(s) with no spot: {', '.join(orphans)}"),
|
|
]
|
|
|
|
|
|
# ── notes ─────────────────────────────────────────────────────────────────────
|
|
|
|
def spots(inv: dict) -> list[str]:
|
|
"""Every place a note can go. Exports by name; the fixed sections by role."""
|
|
named = [n["label"] for n in inv["nodes"] if n["kind"] == "export"]
|
|
return list(SPOTS_FIXED[:3]) + named + list(SPOTS_FIXED[3:])
|
|
|
|
|
|
def load_notes(notes_dir: Path = NOTES) -> dict[str, str]:
|
|
"""spot -> the note's text. A stub (only the scaffold comment) counts as empty."""
|
|
out = {}
|
|
if notes_dir.is_dir():
|
|
for p in sorted(notes_dir.glob("*.md")):
|
|
body = re.sub(r"<!--.*?-->", "", p.read_text(), flags=re.S).strip()
|
|
out[p.stem] = body
|
|
return out
|
|
|
|
|
|
def scaffold(inv: dict, notes_dir: Path = NOTES) -> list[str]:
|
|
"""Write an empty stub for every spot that has no note. Never overwrites."""
|
|
notes_dir.mkdir(parents=True, exist_ok=True)
|
|
made = []
|
|
for spot in spots(inv):
|
|
p = notes_dir / f"{spot}.md"
|
|
if not p.exists():
|
|
p.write_text(STUB.format(spot=spot))
|
|
made.append(spot)
|
|
return made
|
|
|
|
|
|
# ── the reference ─────────────────────────────────────────────────────────────
|
|
|
|
def _cell(text) -> str:
|
|
return str(text).replace("|", "\\|").replace("\n", " ") if text not in (None, "") else "—"
|
|
|
|
|
|
def _code(text) -> str:
|
|
return f"`{_cell(text)}`" if text not in (None, "") else "—"
|
|
|
|
|
|
def render(inv: dict, notes: dict[str, str]) -> str:
|
|
by_id = {n["id"]: n for n in inv["nodes"]}
|
|
out_edges: dict[str, list] = {}
|
|
for e in inv["edges"]:
|
|
out_edges.setdefault(e["source"], []).append(e)
|
|
binds: dict[str, list] = {}
|
|
for e in inv["edges"]:
|
|
if e["kind"] == "binds":
|
|
binds.setdefault(e["target"], []).append(by_id[e["source"]]["label"])
|
|
larder = inv["meta"]["larder"]
|
|
read = larder["seen"] - len([f for f in larder["failed"] if f["error"].startswith("no reader")])
|
|
|
|
L = []
|
|
|
|
def note(spot):
|
|
if notes.get(spot):
|
|
L.extend([notes[spot], ""])
|
|
|
|
L += ["# common/ui — reference", "",
|
|
"> Generated from `common/ui/src` by `make reference` in common/ui "
|
|
"(`python3 -m common.slice reference`).",
|
|
"> Do not edit this page: the next build replaces it. Prose goes in "
|
|
"`soleprint/common/ui/notes/<spot>.md`,",
|
|
"> and `make reference-spots` lists every spot.", ""]
|
|
note("_intro")
|
|
|
|
L += ["## What came in", ""]
|
|
note("larder")
|
|
L += [f"`{larder['identity']}` — **{larder['seen']} file(s) seen, {read} read, "
|
|
f"{len(larder['failed'])} failed**; {larder['extra']['exports']} exports and "
|
|
f"{larder['extra']['types']} types in the barrel.", ""]
|
|
if larder["failed"]:
|
|
L += ["| file | why it is not (fully) read |", "| --- | --- |"]
|
|
L += [f"| `{f['name']}` | {_cell(f['error'])} |" for f in larder["failed"]]
|
|
L.append("")
|
|
|
|
exports = [n for n in inv["nodes"] if n["kind"] == "export"]
|
|
L += ["## Contents", ""]
|
|
note("contents")
|
|
L += ["What each export costs a consumer that takes it: the files it pulls in, the packages it",
|
|
"brings, and whether a plain-HTML part exists for it (`common/theme/parts`).", "",
|
|
"| export | kind | file | pulls in | packages | size | plain part |",
|
|
"| --- | --- | --- | --- | --- | --- | --- |"]
|
|
for n in exports:
|
|
a = n["attrs"]
|
|
f = by_id[f"file:{a['file']}"]
|
|
pulls = ", ".join(p.rsplit("/", 1)[-1] for p in a["closure"] if p != a["file"]) or "—"
|
|
L.append(f"| [`{n['label']}`](#{n['label'].lower()}) | {f['kind']} | `{a['file']}` | {pulls} | "
|
|
f"{', '.join(a['packages']) or '—'} | {a['bytes'] / 1024:.1f} KB | "
|
|
f"{', '.join(binds.get(f['id'], [])) or '—'} |")
|
|
L.append("")
|
|
|
|
for group in ORDER:
|
|
members = [n for n in exports if _group(n["attrs"]["file"]) == group]
|
|
if not members:
|
|
continue
|
|
L += [f"## {group.capitalize() if group else 'Library'}", ""]
|
|
for n in members:
|
|
a = n["attrs"]
|
|
f = by_id[f"file:{a['file']}"]
|
|
fa = f["attrs"]
|
|
L += [f"### {n['label']}", ""]
|
|
if fa.get("summary"):
|
|
L += [fa["summary"], ""]
|
|
note(n["label"])
|
|
meta = [f"`{a['file']}` · {a['bytes'] / 1024:.1f} KB with its closure",
|
|
f"packages: {', '.join(f'`{p}` {v}' for p, v in a['packages'].items()) or 'none'}"]
|
|
if binds.get(f["id"]):
|
|
meta.append("plain part: " + ", ".join(f"`{p}`" for p in binds[f["id"]]))
|
|
L += [" · ".join(meta), ""]
|
|
if fa.get("error"):
|
|
L += [f"**Not fully read:** {fa['error']}", ""]
|
|
if fa.get("props"):
|
|
L += ["| prop | type | required | default | |", "| --- | --- | --- | --- | --- |"]
|
|
L += [f"| `{p['name']}` | {_code(p['type'])} | {'yes' if p['required'] else 'no'} | "
|
|
f"{_code(p['default'])} | {_cell(p['doc'])} |" for p in fa["props"]]
|
|
L.append("")
|
|
if fa.get("models"):
|
|
L += ["| v-model | type | default |", "| --- | --- | --- |"]
|
|
L += [f"| `v-model:{m['name']}` | {_code(m['type'])} | {_code(m['default'])} |"
|
|
for m in fa["models"]]
|
|
L.append("")
|
|
if fa.get("emits"):
|
|
L += ["| emits | payload | |", "| --- | --- | --- |"]
|
|
L += [f"| `{e['name']}` | {_code(e['type'])} | {_cell(e['doc'])} |" for e in fa["emits"]]
|
|
L.append("")
|
|
if fa.get("slots"):
|
|
L += ["slots: " + ", ".join(f"`#{s}`" for s in fa["slots"]), ""]
|
|
# A .ts export's own doc comment (a class or function's) is its
|
|
# description; an SFC's is the file summary, already printed above.
|
|
for d in (d for d in fa.get("declarations", []) if d["name"] == a["local"]):
|
|
if d["doc"] and d["doc"] != fa.get("summary"):
|
|
L += [d["doc"], ""]
|
|
if a["tokens"]:
|
|
L += ["reads: " + " ".join(f"`{t}`" for t in a["tokens"]), ""]
|
|
|
|
types = [n for n in inv["nodes"] if n["kind"] == "type"]
|
|
L += ["## Types", ""]
|
|
note("types")
|
|
L += ["Types ride with the file that declares them: taking the export takes its types.", "",
|
|
"| type | declared in | |", "| --- | --- | --- |"]
|
|
for n in types:
|
|
f = by_id[f"file:{n['attrs']['file']}"]
|
|
decl = next((d for d in f["attrs"].get("declarations", []) if d["name"] == n["attrs"]["local"]), None)
|
|
L.append(f"| `{n['label']}` | `{n['attrs']['file']}` | {_cell(decl['kind'] if decl else '')} |")
|
|
L.append("")
|
|
|
|
toks = [n for n in inv["nodes"] if n["kind"] == "token"]
|
|
readers: dict[str, list] = {}
|
|
for e in inv["edges"]:
|
|
if e["kind"] == "reads":
|
|
readers.setdefault(e["target"], []).append(by_id[e["source"]]["label"])
|
|
L += ["## Tokens", ""]
|
|
note("tokens")
|
|
L += ["Every custom property the library defines or reads. `library` is `src/tokens.css`, what",
|
|
"Vue consumers render today; `theme` is the contract plain pages use",
|
|
"(`common/theme/tokens.css` + `themes/soleprint.css`). A row where the two differ is a",
|
|
"visible change for a consumer that switches.", "",
|
|
"| token | library | theme | read by |", "| --- | --- | --- | --- |"]
|
|
for n in toks:
|
|
a = n["attrs"]
|
|
flag = " ⚠" if a["library"] and a["theme"] and a["library"] != a["theme"] else ""
|
|
who = ", ".join(sorted(set(readers.get(n["id"], [])))) or "*nobody*"
|
|
L.append(f"| `{n['label']}`{flag} | {_code(a['library'])} | {_code(a['theme'])} | {who} |")
|
|
L.append("")
|
|
|
|
L += ["## What came out", ""]
|
|
note("book")
|
|
kinds: dict[str, int] = {}
|
|
for n in inv["nodes"]:
|
|
kinds[n["kind"]] = kinds.get(n["kind"], 0) + 1
|
|
ekinds: dict[str, int] = {}
|
|
for e in inv["edges"]:
|
|
ekinds[e["kind"]] = ekinds.get(e["kind"], 0) + 1
|
|
L += ["nodes: " + " · ".join(f"{v} {k}" for k, v in sorted(kinds.items())),
|
|
"", "edges: " + " · ".join(f"{v} {k}" for k, v in sorted(ekinds.items())), ""]
|
|
L += [f"- {'ok ' if ok else '**FAIL**'} {msg}" for ok, msg in reconcile(inv, notes)]
|
|
L.append("")
|
|
note("_end")
|
|
return "\n".join(L).rstrip() + "\n"
|
|
|
|
|
|
# ── writing, and checking without writing ─────────────────────────────────────
|
|
|
|
@dataclass
|
|
class Result:
|
|
written: list = field(default_factory=list)
|
|
unchanged: list = field(default_factory=list)
|
|
stale: list = field(default_factory=list)
|
|
edited: list = field(default_factory=list)
|
|
removed: list = field(default_factory=list)
|
|
reconciled: list = field(default_factory=list)
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return not self.stale and not self.edited and all(ok for ok, _ in self.reconciled)
|
|
|
|
|
|
def _sha(text: str) -> str:
|
|
return hashlib.sha256(text.encode()).hexdigest()
|
|
|
|
|
|
def outputs(inv: dict, notes: dict[str, str]) -> dict[str, str]:
|
|
return {INVENTORY: json.dumps(inv, indent=2, sort_keys=True) + "\n",
|
|
PAGE: render(inv, notes)}
|
|
|
|
|
|
def build(docs: Path = SITE, *, write: bool, force: bool = False, src: Path = graph.SRC,
|
|
notes_dir: Path = NOTES) -> Result:
|
|
inv = inventory(src)
|
|
notes = load_notes(notes_dir)
|
|
files = outputs(inv, notes)
|
|
res = Result(reconciled=reconcile(inv, notes))
|
|
manifest = docs / MANIFEST
|
|
previous = json.loads(manifest.read_text()).get("files", {}) if manifest.exists() else {}
|
|
|
|
for name, text in files.items():
|
|
path = docs / name
|
|
on_disk = path.read_text() if path.exists() else None
|
|
if on_disk is not None and name in previous and _sha(on_disk) != previous[name]:
|
|
# Somebody edited a generated file. That text would be lost on the
|
|
# next build, so it is named and refused rather than overwritten.
|
|
res.edited.append(name)
|
|
if not force:
|
|
continue
|
|
if on_disk == text:
|
|
res.unchanged.append(name)
|
|
continue
|
|
res.stale.append(name)
|
|
if write:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
res.written.append(name)
|
|
|
|
gone = [n for n in previous if n not in files and (docs / n).exists()]
|
|
res.removed = gone
|
|
if write and (not res.edited or force):
|
|
for n in gone:
|
|
(docs / n).unlink()
|
|
manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
manifest.write_text(json.dumps({"generator": "common.slice reference",
|
|
"files": {n: _sha(t) for n, t in files.items()}},
|
|
indent=2, sort_keys=True) + "\n")
|
|
if res.written:
|
|
res.stale = []
|
|
return res
|