@mriekki/lean 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/assets/components.json +3 -3
  2. package/assets/core/distribution.json +8 -0
  3. package/assets/deployment/distribution.json +2 -0
  4. package/assets/generated/ASSEMBLY.json +73 -23
  5. package/assets/generated/core/core/instructions/lean-docs.instructions.md +33 -2
  6. package/assets/generated/core/core/instructions/lean-workflow.instructions.md +42 -0
  7. package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +46 -6
  8. package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +29 -6
  9. package/assets/generated/core/core/learning/LEARNING_MAP.md +37 -2
  10. package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +15 -7
  11. package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +299 -19
  12. package/assets/generated/core/core/process/COACHING_GUIDE.md +35 -2
  13. package/assets/generated/core/core/process/DATABASE_LEARNING_WORKBENCH.md +153 -102
  14. package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +108 -71
  15. package/assets/generated/core/core/process/LEAN_ENGINEERING_GUIDE.md +317 -0
  16. package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +324 -0
  17. package/assets/generated/core/core/process/SYSTEM_DESIGN_LEARNING_WORKBENCH.md +520 -0
  18. package/assets/generated/core/core/skills/coach/SKILL.md +31 -4
  19. package/assets/generated/core/core/skills/db-lab/SKILL.md +59 -16
  20. package/assets/generated/core/core/skills/deliver/SKILL.md +77 -5
  21. package/assets/generated/core/core/skills/deploy/SKILL.md +164 -121
  22. package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +223 -250
  23. package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
  24. package/assets/generated/core/core/skills/next-step/SKILL.md +21 -6
  25. package/assets/generated/core/core/skills/system-lab/SKILL.md +333 -0
  26. package/assets/generated/core/core/skills/tear-down/SKILL.md +101 -61
  27. package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
  28. package/assets/generated/core/tools/docs_site/docs_site.py +382 -0
  29. package/assets/generated/core/tools/system_lab/system_workbench.py +243 -0
  30. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +5 -1
  31. package/assets/generated/deployment/src/lean_poc_deployment/local_cli.py +112 -0
  32. package/assets/generated/deployment/src/lean_poc_deployment/local_runtime.py +523 -0
  33. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +49 -35
  34. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +31 -12
  35. package/package.json +1 -1
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import html
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import webbrowser
13
+ from datetime import datetime, timezone
14
+ from functools import partial
15
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ IGNORE_DIRS = {".git", ".lean", "node_modules", "bin", "obj", "dist", "build", "target", ".venv", "venv", "__pycache__"}
20
+ DEFAULT_CACHE = Path(".lean/docs-cache")
21
+ DEFAULT_SITE = Path(".lean/docs-site")
22
+ MANIFEST = Path("docs/lean-docs.json")
23
+
24
+
25
+ class DocsSiteError(RuntimeError):
26
+ pass
27
+
28
+
29
+ def _read_json(path: Path) -> dict[str, Any] | None:
30
+ try:
31
+ value = json.loads(path.read_text(encoding="utf-8"))
32
+ except FileNotFoundError:
33
+ return None
34
+ except json.JSONDecodeError as exc:
35
+ raise DocsSiteError(f"Invalid JSON in {path}: {exc}") from exc
36
+ if not isinstance(value, dict):
37
+ raise DocsSiteError(f"Expected an object in {path}")
38
+ return value
39
+
40
+
41
+ def _git(root: Path, *args: str) -> str | None:
42
+ try:
43
+ result = subprocess.run(
44
+ ["git", "-C", str(root), *args],
45
+ text=True,
46
+ encoding="utf-8",
47
+ errors="replace",
48
+ capture_output=True,
49
+ timeout=5,
50
+ check=False,
51
+ )
52
+ except (OSError, subprocess.SubprocessError):
53
+ return None
54
+ value = (result.stdout or "").strip()
55
+ return value if result.returncode == 0 and value else None
56
+
57
+
58
+ def _safe_rel(root: Path, path: Path) -> str:
59
+ try:
60
+ return path.resolve().relative_to(root.resolve()).as_posix()
61
+ except ValueError as exc:
62
+ raise DocsSiteError(f"Documentation path escapes repository root: {path}") from exc
63
+
64
+
65
+ def _markdown_title(content: str, fallback: str) -> str:
66
+ for line in content.splitlines():
67
+ match = re.match(r"^#\s+(.+?)\s*$", line)
68
+ if match:
69
+ return re.sub(r"[`*_]", "", match.group(1)).strip()
70
+ return fallback
71
+
72
+
73
+ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[list[dict[str, Any]], list[str]]:
74
+ docs: list[dict[str, Any]] = []
75
+ warnings: list[str] = []
76
+ seen: set[str] = set()
77
+
78
+ if manifest is not None:
79
+ if manifest.get("schemaVersion") != 1:
80
+ warnings.append("docs/lean-docs.json schemaVersion is not 1")
81
+ raw = manifest.get("documents")
82
+ if raw is not None and not isinstance(raw, list):
83
+ warnings.append("docs/lean-docs.json documents must be an array")
84
+ raw = []
85
+ for index, item in enumerate(raw or []):
86
+ if not isinstance(item, dict) or not isinstance(item.get("path"), str):
87
+ warnings.append(f"docs/lean-docs.json documents[{index}] is invalid")
88
+ continue
89
+ rel = Path(item["path"]).as_posix().lstrip("/")
90
+ if rel.startswith("../") or "/../" in rel:
91
+ warnings.append(f"Skipped unsafe documentation path: {item['path']}")
92
+ continue
93
+ path = root / rel
94
+ seen.add(rel)
95
+ if not path.is_file():
96
+ if item.get("optional") is not True:
97
+ warnings.append(f"Listed documentation is not present: {rel}")
98
+ continue
99
+ content = path.read_text(encoding="utf-8", errors="replace")
100
+ docs.append(
101
+ {
102
+ "path": rel,
103
+ "title": str(item.get("title") or _markdown_title(content, path.stem)),
104
+ "section": str(item.get("section") or "Other"),
105
+ "type": str(item.get("type") or "markdown"),
106
+ "ownership": str(item.get("ownership") or "project"),
107
+ "authority": str(item.get("authority") or "reference"),
108
+ "content": content,
109
+ }
110
+ )
111
+
112
+ candidates: list[Path] = []
113
+ for name in ("PROJECT_BRIEF.md", "README.md"):
114
+ path = root / name
115
+ if path.is_file():
116
+ candidates.append(path)
117
+ docs_root = root / "docs"
118
+ if docs_root.is_dir():
119
+ for base, dirs, names in os.walk(docs_root):
120
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
121
+ for name in names:
122
+ if name.lower().endswith(".md"):
123
+ candidates.append(Path(base) / name)
124
+
125
+ for path in sorted(candidates):
126
+ rel = _safe_rel(root, path)
127
+ if rel in seen:
128
+ continue
129
+ content = path.read_text(encoding="utf-8", errors="replace")
130
+ section = "Overview" if rel in {"README.md", "PROJECT_BRIEF.md", "docs/index.md"} else rel.split("/")[1].replace("-", " ").title() if rel.startswith("docs/") and "/" in rel else "Other"
131
+ docs.append(
132
+ {
133
+ "path": rel,
134
+ "title": _markdown_title(content, path.stem),
135
+ "section": section,
136
+ "type": "markdown",
137
+ "ownership": "unknown",
138
+ "authority": "reference",
139
+ "content": content,
140
+ }
141
+ )
142
+
143
+ docs.sort(key=lambda item: (item["section"].lower(), item["path"].lower()))
144
+ return docs, warnings
145
+
146
+
147
+ def _lab_status(root: Path, kind: str, current_commit: str | None) -> dict[str, Any]:
148
+ cache = root / DEFAULT_CACHE / kind
149
+ data_path = cache / "workbench.json"
150
+ index_path = cache / "index.html"
151
+ command = (
152
+ "python tools/lean-system-lab/system-workbench.py export --repo . --out .lean/docs-cache/system"
153
+ if kind == "system"
154
+ else "/db-lab"
155
+ )
156
+ result: dict[str, Any] = {
157
+ "kind": kind,
158
+ "status": "NOT GENERATED",
159
+ "detail": "No disposable local projection is available.",
160
+ "command": command,
161
+ "hasIndex": index_path.is_file(),
162
+ }
163
+ if not data_path.is_file():
164
+ return result
165
+ try:
166
+ data = json.loads(data_path.read_text(encoding="utf-8"))
167
+ except (OSError, json.JSONDecodeError):
168
+ result["status"] = "AVAILABLE / FRESHNESS UNKNOWN"
169
+ result["detail"] = "A local projection exists, but its evidence metadata could not be read."
170
+ return result
171
+
172
+ generated = data.get("generatedAt") if isinstance(data, dict) else None
173
+ if generated:
174
+ result["generatedAt"] = str(generated)
175
+
176
+ recorded_commit: str | None = None
177
+ if isinstance(data, dict):
178
+ repository = data.get("repository")
179
+ if isinstance(repository, dict) and repository.get("commit"):
180
+ recorded_commit = str(repository["commit"])
181
+ elif data.get("repositoryCommit"):
182
+ recorded_commit = str(data["repositoryCommit"])
183
+
184
+ if recorded_commit and current_commit:
185
+ result["recordedCommit"] = recorded_commit
186
+ if recorded_commit == current_commit:
187
+ result["status"] = "CURRENT"
188
+ result["detail"] = f"Projection evidence is bound to current commit {current_commit[:10]}."
189
+ else:
190
+ result["status"] = "STALE"
191
+ result["detail"] = f"Projection records {recorded_commit[:10]}; checkout is {current_commit[:10]}."
192
+ else:
193
+ result["status"] = "AVAILABLE / FRESHNESS UNKNOWN"
194
+ result["detail"] = "A projection exists, but there is not enough evidence to prove it matches the current checkout."
195
+ return result
196
+
197
+
198
+ def _prepare_system_lab(root: Path) -> str | None:
199
+ tool = root / "tools/lean-system-lab/system-workbench.py"
200
+ if not tool.is_file():
201
+ return None
202
+ out = root / DEFAULT_CACHE / "system"
203
+ out.parent.mkdir(parents=True, exist_ok=True)
204
+ try:
205
+ result = subprocess.run(
206
+ [sys.executable, str(tool), "export", "--repo", str(root), "--out", str(out)],
207
+ cwd=str(root),
208
+ text=True,
209
+ encoding="utf-8",
210
+ errors="replace",
211
+ capture_output=True,
212
+ timeout=45,
213
+ check=False,
214
+ )
215
+ except (OSError, subprocess.SubprocessError) as exc:
216
+ return f"System Lab preparation unavailable: {exc}"
217
+ if result.returncode != 0:
218
+ detail = (result.stderr or result.stdout or "").strip().splitlines()
219
+ return "System Lab preparation failed: " + (detail[-1] if detail else f"exit {result.returncode}")
220
+ return None
221
+
222
+
223
+ def _copy_lab_cache(root: Path, out: Path, kind: str) -> None:
224
+ source = root / DEFAULT_CACHE / kind
225
+ if not source.is_dir():
226
+ return
227
+ target = out / "labs" / kind
228
+ if target.exists():
229
+ shutil.rmtree(target)
230
+ shutil.copytree(source, target)
231
+
232
+
233
+ def _site_payload(root: Path) -> dict[str, Any]:
234
+ manifest = _read_json(root / MANIFEST)
235
+ docs, warnings = _manifest_documents(root, manifest)
236
+ current_commit = _git(root, "rev-parse", "HEAD")
237
+ branch = _git(root, "branch", "--show-current")
238
+ site = manifest.get("site") if isinstance(manifest, dict) and isinstance(manifest.get("site"), dict) else {}
239
+ title = str(site.get("title") or root.name)
240
+ entry = str(site.get("entry") or ("PROJECT_BRIEF.md" if (root / "PROJECT_BRIEF.md").is_file() else "README.md"))
241
+ return {
242
+ "title": title,
243
+ "entry": entry,
244
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
245
+ "repository": {"name": root.name, "commit": current_commit, "branch": branch},
246
+ "documents": docs,
247
+ "warnings": warnings,
248
+ "labs": {
249
+ "system": _lab_status(root, "system", current_commit),
250
+ "database": _lab_status(root, "database", current_commit),
251
+ },
252
+ }
253
+
254
+
255
+ def _html_document(payload: dict[str, Any]) -> str:
256
+ data = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
257
+ title = html.escape(str(payload["title"]))
258
+ return f'''<!doctype html>
259
+ <html lang="en">
260
+ <head>
261
+ <meta charset="utf-8">
262
+ <meta name="viewport" content="width=device-width,initial-scale=1">
263
+ <title>{title} — Project Docs</title>
264
+ <style>
265
+ :root{{color-scheme:light dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--line:#8885;--muted:#777;--panel:color-mix(in srgb,Canvas 96%,CanvasText 4%);--accent:LinkText}}
266
+ *{{box-sizing:border-box}} body{{margin:0;background:Canvas;color:CanvasText}} a{{color:LinkText}}
267
+ header{{position:sticky;top:0;z-index:5;display:flex;gap:12px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line);background:Canvas}}
268
+ header h1{{font-size:17px;margin:0;white-space:nowrap}} #search{{flex:1;max-width:620px;padding:7px 9px;font:inherit}}
269
+ .repo{{font-size:12px;color:var(--muted);margin-left:auto}}
270
+ .layout{{display:grid;grid-template-columns:300px minmax(0,1fr) 300px;min-height:calc(100vh - 52px)}}
271
+ nav{{border-right:1px solid var(--line);padding:12px;overflow:auto}} nav h3{{font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin:16px 4px 6px;color:var(--muted)}}
272
+ nav button{{width:100%;text-align:left;border:0;background:none;color:inherit;padding:7px;border-radius:6px;cursor:pointer}} nav button:hover,nav button.active{{background:var(--panel)}} nav small{{display:block;color:var(--muted);overflow:hidden;text-overflow:ellipsis}}
273
+ main{{padding:28px clamp(18px,4vw,64px);min-width:0}} article{{max-width:980px;margin:auto;line-height:1.6}} article h1{{font-size:2rem}} article h2{{margin-top:2.2rem;border-bottom:1px solid var(--line);padding-bottom:.25rem}} article pre{{overflow:auto;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}} article code{{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}} article :not(pre)>code{{padding:.1em .3em;border-radius:4px;background:var(--panel)}} article table{{border-collapse:collapse;width:100%;display:block;overflow:auto}} article th,article td{{border:1px solid var(--line);padding:7px 9px;text-align:left;vertical-align:top}} article blockquote{{border-left:4px solid var(--line);margin-left:0;padding-left:14px;color:var(--muted)}}
274
+ aside{{border-left:1px solid var(--line);padding:12px;overflow:auto}} .card{{border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:10px;background:var(--panel)}} .card h3{{margin:0 0 6px;font-size:14px}} .status{{font-weight:700;font-size:12px}} .muted{{color:var(--muted)}} .badge{{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:2px 6px;font-size:11px;margin-right:4px}} .warning{{border-left:4px solid #b8860b;padding:8px 10px;background:var(--panel);margin:10px 0}}
275
+ button.copy{{font:inherit;font-size:12px;padding:4px 7px}} .mermaid-lite{{overflow:auto;border:1px solid var(--line);border-radius:8px;padding:8px;background:var(--panel)}} .mermaid-lite svg{{min-width:680px;width:100%;height:auto}} .mermaid-fallback{{white-space:pre-wrap;font-family:ui-monospace,monospace;font-size:12px}}
276
+ @media(max-width:1100px){{.layout{{grid-template-columns:250px 1fr}}aside{{display:none}}}} @media(max-width:760px){{.layout{{display:block}}nav{{border-right:0;border-bottom:1px solid var(--line);max-height:280px}}header{{flex-wrap:wrap}}.repo{{width:100%;margin-left:0}}}}
277
+ </style>
278
+ </head>
279
+ <body>
280
+ <header><h1 id="siteTitle"></h1><input id="search" type="search" placeholder="Search project docs"><div class="repo" id="repo"></div></header>
281
+ <div class="layout"><nav id="nav"></nav><main><article id="doc"></article></main><aside id="side"></aside></div>
282
+ <script>
283
+ const SITE={data};
284
+ const state={{path:SITE.entry,query:''}};
285
+ const byPath=new Map(SITE.documents.map(d=>[d.path,d]));
286
+ function esc(v){{return String(v??'').replace(/[&<>"']/g,c=>({{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}})[c])}}
287
+ function safeHref(v){{let s=String(v||'').trim();if(!s)return '#';if(/^https?:\/\//i.test(s)||/^mailto:/i.test(s)||/^#/.test(s)||/^[.\w\/-]+(?:#[\w-]+)?$/.test(s))return s;return '#'}}
288
+ function inline(v){{let s=esc(v);s=s.replace(/`([^`]+)`/g,'<code>$1</code>');s=s.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');s=s.replace(/\*([^*]+)\*/g,'<em>$1</em>');s=s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,t,u)=>`<a href="${{esc(safeHref(u))}}" target="${{/^https?:/.test(u)?'_blank':'_self'}}">${{t}}</a>`);return s}}
289
+ function md(text){{let lines=String(text||'').replace(/\r/g,'').split('\n'),out=[],i=0;while(i<lines.length){{let l=lines[i];if(/^```/.test(l)){{let lang=l.slice(3).trim(),buf=[];i++;while(i<lines.length&&!/^```/.test(lines[i]))buf.push(lines[i++]);i++;let src=buf.join('\n');if(lang==='mermaid')out.push(`<div class="mermaid-lite" data-mermaid="${{esc(src)}}"></div>`);else out.push(`<pre><code>${{esc(src)}}</code></pre>`);continue}}if(/^\|.*\|\s*$/.test(l)&&i+1<lines.length&&/^\|?\s*:?-+/.test(lines[i+1])){{let rows=[];rows.push(l);i+=2;while(i<lines.length&&/^\|.*\|\s*$/.test(lines[i]))rows.push(lines[i++]);let cells=x=>x.trim().replace(/^\||\|$/g,'').split('|').map(c=>c.trim());let h=cells(rows[0]);out.push('<table><thead><tr>'+h.map(c=>`<th>${{inline(c)}}</th>`).join('')+'</tr></thead><tbody>'+rows.slice(1).map(r=>'<tr>'+cells(r).map(c=>`<td>${{inline(c)}}</td>`).join('')+'</tr>').join('')+'</tbody></table>');continue}}let m=l.match(/^(#{{1,6}})\s+(.+)$/);if(m){{let n=m[1].length;out.push(`<h${{n}}>${{inline(m[2])}}</h${{n}}>`);i++;continue}}if(/^---+\s*$/.test(l)){{out.push('<hr>');i++;continue}}if(/^>\s?/.test(l)){{let b=[];while(i<lines.length&&/^>\s?/.test(lines[i]))b.push(lines[i++].replace(/^>\s?/,''));out.push(`<blockquote>${{md(b.join('\n'))}}</blockquote>`);continue}}if(/^[-*+]\s+/.test(l)){{let a=[];while(i<lines.length&&/^[-*+]\s+/.test(lines[i]))a.push(lines[i++].replace(/^[-*+]\s+/,''));out.push('<ul>'+a.map(x=>`<li>${{inline(x)}}</li>`).join('')+'</ul>');continue}}if(/^\d+\.\s+/.test(l)){{let a=[];while(i<lines.length&&/^\d+\.\s+/.test(lines[i]))a.push(lines[i++].replace(/^\d+\.\s+/,''));out.push('<ol>'+a.map(x=>`<li>${{inline(x)}}</li>`).join('')+'</ol>');continue}}if(!l.trim()){{i++;continue}}let p=[l];i++;while(i<lines.length&&lines[i].trim()&&!/^(#|```|>|[-*+]\s+|\d+\.\s+)/.test(lines[i])&&!/^\|.*\|\s*$/.test(lines[i]))p.push(lines[i++]);out.push(`<p>${{inline(p.join(' '))}}</p>`);}}return out.join('\n')}}
290
+ function mermaidLite(el){{let src=el.dataset.mermaid||'',first=src.split('\n').find(x=>x.trim())||'';let mm=first.match(/^\s*flowchart\s+(LR|TD)/i);if(!mm){{el.innerHTML=`<div class="mermaid-fallback">${{esc(src)}}</div>`;return}}let dir=mm[1].toUpperCase(),nodes=new Map(),edges=[];for(let raw of src.split('\n').slice(1)){{let l=raw.trim();if(!l||l.startsWith('%%'))continue;let re=/([A-Za-z0-9_-]+)(?:\[([^\]]+)\])?\s*--+>?\s*([A-Za-z0-9_-]+)(?:\[([^\]]+)\])?/;let x=l.match(re);if(x){{nodes.set(x[1],(x[2]||nodes.get(x[1])||x[1]).replace(/<br\s*\/?>/gi,' / '));nodes.set(x[3],(x[4]||nodes.get(x[3])||x[3]).replace(/<br\s*\/?>/gi,' / '));edges.push([x[1],x[3]]);continue}}let n=l.match(/^([A-Za-z0-9_-]+)\[([^\]]+)\]/);if(n)nodes.set(n[1],n[2].replace(/<br\s*\/?>/gi,' / '))}}let ids=[...nodes.keys()];if(!ids.length){{el.innerHTML=`<div class="mermaid-fallback">${{esc(src)}}</div>`;return}}let w=dir==='LR'?Math.max(720,ids.length*190):900,h=dir==='LR'?220:Math.max(260,Math.ceil(ids.length/4)*130),pos=new Map();ids.forEach((id,k)=>{{if(dir==='LR')pos.set(id,{{x:30+k*180,y:70}});else pos.set(id,{{x:30+(k%4)*215,y:35+Math.floor(k/4)*120}})}});let svg=[`<svg viewBox="0 0 ${{w}} ${{h}}" role="img"><defs><marker id="arr" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="currentColor"/></marker></defs>`];for(let [a,b] of edges){{let p=pos.get(a),q=pos.get(b);if(!p||!q)continue;svg.push(`<line x1="${{p.x+140}}" y1="${{p.y+35}}" x2="${{q.x}}" y2="${{q.y+35}}" stroke="currentColor" stroke-opacity=".55" marker-end="url(#arr)"/>`)}}for(let id of ids){{let p=pos.get(id),label=nodes.get(id);svg.push(`<rect x="${{p.x}}" y="${{p.y}}" width="140" height="70" rx="9" fill="Canvas" stroke="currentColor" stroke-opacity=".55"/><foreignObject x="${{p.x+8}}" y="${{p.y+8}}" width="124" height="54"><div xmlns="http://www.w3.org/1999/xhtml" style="font:12px system-ui;text-align:center;display:flex;align-items:center;justify-content:center;height:54px">${{esc(label)}}</div></foreignObject>`)}}svg.push('</svg>');el.innerHTML=svg.join('')}}
291
+ function nav(){{let q=state.query.toLowerCase(),groups=new Map();for(let d of SITE.documents){{if(q&&!`${{d.title}} ${{d.path}} ${{d.content}}`.toLowerCase().includes(q))continue;let a=groups.get(d.section)||[];a.push(d);groups.set(d.section,a)}}let n=document.querySelector('#nav');n.innerHTML='';for(let [g,ds] of groups){{let h=document.createElement('h3');h.textContent=g;n.appendChild(h);for(let d of ds){{let b=document.createElement('button');b.className=d.path===state.path?'active':'';b.innerHTML=`${{esc(d.title)}}<small>${{esc(d.path)}}</small>`;b.onclick=()=>{{state.path=d.path;render()}};n.appendChild(b)}}}}}}
292
+ function labCard(label,l){{let link=l.hasIndex?`<p><a href="labs/${{l.kind}}/index.html" target="_blank">Open existing projection</a></p>`:'';return `<div class="card"><h3>${{esc(label)}}</h3><div class="status">${{esc(l.status)}}</div><p class="muted">${{esc(l.detail)}}</p>${{link}}<p><code>${{esc(l.command)}}</code></p><button class="copy" data-copy="${{esc(l.command)}}">Copy command</button></div>`}}
293
+ function side(d){{let s=`<div class="card"><h3>Document</h3><span class="badge">${{esc(d.ownership)}}</span><span class="badge">${{esc(d.authority)}}</span><p class="muted">${{esc(d.path)}}</p></div>`;s+=labCard('System Lab',SITE.labs.system)+labCard('Database Lab',SITE.labs.database);if(SITE.warnings.length)s+=`<div class="card"><h3>Documentation checks</h3>${{SITE.warnings.map(x=>`<div class="warning">${{esc(x)}}</div>`).join('')}}</div>`;s+=`<div class="card"><h3>Learning</h3><p><code>/system-lab</code><br><code>/db-lab</code><br><code>/code-walkthrough</code><br><code>/learning-review</code><br><code>/coach</code></p></div>`;document.querySelector('#side').innerHTML=s;document.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>navigator.clipboard?.writeText(b.dataset.copy))}}
294
+ function render(){{let d=byPath.get(state.path)||SITE.documents[0];if(!d){{document.querySelector('#doc').innerHTML='<h1>No Markdown documents found</h1>';return}}state.path=d.path;document.querySelector('#doc').innerHTML=md(d.content);document.querySelectorAll('.mermaid-lite').forEach(mermaidLite);nav();side(d);history.replaceState(null,'','#'+encodeURIComponent(d.path))}}
295
+ document.querySelector('#siteTitle').textContent=SITE.title;document.querySelector('#repo').textContent=`${{SITE.repository.branch||''}} ${{SITE.repository.commit?SITE.repository.commit.slice(0,10):'no git'}}`;document.querySelector('#search').oninput=e=>{{state.query=e.target.value;nav()}};let h=decodeURIComponent(location.hash.slice(1));if(byPath.has(h))state.path=h;render();
296
+ </script>
297
+ </body></html>'''
298
+
299
+
300
+ def build(root: Path, out: Path, *, prepare_system_lab: bool = False) -> tuple[Path, dict[str, Any]]:
301
+ root = root.resolve()
302
+ out = out.resolve()
303
+ if not root.is_dir():
304
+ raise DocsSiteError(f"Repository root does not exist: {root}")
305
+ prep_warning = _prepare_system_lab(root) if prepare_system_lab else None
306
+ payload = _site_payload(root)
307
+ if prep_warning:
308
+ payload["warnings"].append(prep_warning)
309
+ if out.exists():
310
+ shutil.rmtree(out)
311
+ out.mkdir(parents=True, exist_ok=True)
312
+ _copy_lab_cache(root, out, "system")
313
+ _copy_lab_cache(root, out, "database")
314
+ index = out / "index.html"
315
+ index.write_text(_html_document(payload), encoding="utf-8")
316
+ (out / "site.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
317
+ return index, payload
318
+
319
+
320
+ def serve(root: Path, out: Path, port: int, *, open_browser: bool, prepare_system_lab: bool) -> int:
321
+ index, payload = build(root, out, prepare_system_lab=prepare_system_lab)
322
+ handler = partial(SimpleHTTPRequestHandler, directory=str(index.parent))
323
+ server = ThreadingHTTPServer(("127.0.0.1", port), handler)
324
+ actual_port = int(server.server_address[1])
325
+ url = f"http://127.0.0.1:{actual_port}/"
326
+ print(f"Lean Project Docs: {url}")
327
+ print(f"Documents: {len(payload['documents'])}")
328
+ print(f"System Lab: {payload['labs']['system']['status']}")
329
+ print(f"Database Lab: {payload['labs']['database']['status']}")
330
+ if open_browser:
331
+ webbrowser.open(url)
332
+ try:
333
+ server.serve_forever()
334
+ except KeyboardInterrupt:
335
+ pass
336
+ finally:
337
+ server.server_close()
338
+ return 0
339
+
340
+
341
+ def parse_args() -> argparse.Namespace:
342
+ parser = argparse.ArgumentParser(description="Build or serve the dependency-free Lean project documentation site.")
343
+ sub = parser.add_subparsers(dest="command", required=True)
344
+
345
+ build_parser = sub.add_parser("build")
346
+ build_parser.add_argument("--root", type=Path, default=Path("."))
347
+ build_parser.add_argument("--out", type=Path)
348
+ build_parser.add_argument("--prepare-system-lab", action="store_true")
349
+
350
+ serve_parser = sub.add_parser("serve")
351
+ serve_parser.add_argument("--root", type=Path, default=Path("."))
352
+ serve_parser.add_argument("--out", type=Path)
353
+ serve_parser.add_argument("--port", type=int, default=8765)
354
+ serve_parser.add_argument("--open", action="store_true", dest="open_browser")
355
+ serve_parser.add_argument("--no-system-lab", action="store_true")
356
+ return parser.parse_args()
357
+
358
+
359
+ def main() -> int:
360
+ args = parse_args()
361
+ root = args.root.resolve()
362
+ out = args.out.resolve() if args.out else (root / DEFAULT_SITE).resolve()
363
+ try:
364
+ if args.command == "build":
365
+ index, payload = build(root, out, prepare_system_lab=args.prepare_system_lab)
366
+ print(f"Lean Project Docs built: {index}")
367
+ print(f"Documents: {len(payload['documents'])}")
368
+ return 0
369
+ return serve(
370
+ root,
371
+ out,
372
+ args.port,
373
+ open_browser=args.open_browser,
374
+ prepare_system_lab=not args.no_system_lab,
375
+ )
376
+ except DocsSiteError as exc:
377
+ print(f"ERROR: {exc}", file=sys.stderr)
378
+ return 2
379
+
380
+
381
+ if __name__ == "__main__":
382
+ raise SystemExit(main())