devcouncil 0.1.1 → 0.3.0
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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Export DevCouncil's artifact graph as an Open Knowledge Format (OKF) bundle.
|
|
2
|
+
|
|
3
|
+
DevCouncil already keeps a durable Requirement→Task→Evidence→Gap graph; OKF is the natural
|
|
4
|
+
portable wire format for it. This writer renders that graph as a cross-linked directory of
|
|
5
|
+
markdown+frontmatter documents (an :class:`devcouncil.knowledge.okf.OKFBundle`) that any
|
|
6
|
+
OKF-aware agent — or the upstream OKF HTML visualizer — can consume, with the relationships
|
|
7
|
+
expressed as real markdown links so the "linked graph" property holds.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
16
|
+
from devcouncil.indexing.repo_mapper import RepoMap
|
|
17
|
+
from devcouncil.knowledge.okf import OKFBundle, OKFDocument, write_bundle
|
|
18
|
+
|
|
19
|
+
_SLUG_RE = re.compile(r"[^a-zA-Z0-9._-]+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _slug(value: str) -> str:
|
|
23
|
+
return _SLUG_RE.sub("-", value).strip("-") or "item"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _link(rel_from: str, rel_to: str, text: str) -> str:
|
|
27
|
+
"""A markdown link from one bundle document to another, as a relative path."""
|
|
28
|
+
from_parts = rel_from.split("/")[:-1]
|
|
29
|
+
to_parts = rel_to.split("/")
|
|
30
|
+
# Compute a relative path (both live under the same bundle root).
|
|
31
|
+
i = 0
|
|
32
|
+
while i < len(from_parts) and i < len(to_parts) - 1 and from_parts[i] == to_parts[i]:
|
|
33
|
+
i += 1
|
|
34
|
+
up = [".."] * (len(from_parts) - i)
|
|
35
|
+
down = to_parts[i:]
|
|
36
|
+
rel = "/".join(up + down) or rel_to.split("/")[-1]
|
|
37
|
+
return f"[{text}]({rel})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OKFBundleWriter:
|
|
41
|
+
"""Builds an OKF bundle from an :class:`ArtifactGraph` and writes it to disk."""
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def build(
|
|
45
|
+
graph: ArtifactGraph,
|
|
46
|
+
repo_map: RepoMap | None = None,
|
|
47
|
+
project_name: str = "DevCouncil Project",
|
|
48
|
+
timestamp: str = "",
|
|
49
|
+
include_skills: bool = False,
|
|
50
|
+
skills: "list | None" = None,
|
|
51
|
+
include_design: bool = False,
|
|
52
|
+
design=None,
|
|
53
|
+
) -> OKFBundle:
|
|
54
|
+
"""Build the in-memory :class:`OKFBundle` (no filesystem writes).
|
|
55
|
+
|
|
56
|
+
When ``include_skills`` is set and ``skills`` are supplied, each engineering skill
|
|
57
|
+
is rendered as an OKF document (via the shared :mod:`skill_bridge`) under
|
|
58
|
+
``skills/`` and indexed by a ``skills/index.md`` node so the bundle stays a single
|
|
59
|
+
connected, link-valid graph. Default behavior (no skills) is unchanged.
|
|
60
|
+
|
|
61
|
+
Symmetrically, when ``include_design`` is set and a ``design`` (a
|
|
62
|
+
:class:`devcouncil.knowledge.design.DesignSystem`) is supplied, it is rendered as a
|
|
63
|
+
``Design System`` OKF document under ``design/`` and indexed by ``design/index.md``,
|
|
64
|
+
which the root index links. Default behavior (no design) is unchanged.
|
|
65
|
+
"""
|
|
66
|
+
docs: list[OKFDocument] = []
|
|
67
|
+
|
|
68
|
+
# Pre-compute task ids covering each requirement, for cross-linking.
|
|
69
|
+
tasks_for_req: dict[str, list[str]] = {}
|
|
70
|
+
for task in graph.tasks.values():
|
|
71
|
+
for rid in task.requirement_ids:
|
|
72
|
+
tasks_for_req.setdefault(rid, []).append(task.id)
|
|
73
|
+
|
|
74
|
+
# --- Requirements ---
|
|
75
|
+
for req in graph.requirements.values():
|
|
76
|
+
rel = f"requirements/{_slug(req.id)}.md"
|
|
77
|
+
lines = [req.description.strip(), ""]
|
|
78
|
+
if req.acceptance_criteria:
|
|
79
|
+
lines.append("### Acceptance criteria")
|
|
80
|
+
for ac in req.acceptance_criteria:
|
|
81
|
+
flag = "required" if ac.required else "optional"
|
|
82
|
+
lines.append(f"- `{ac.id}` ({ac.verification_method}, {flag}): {ac.description}")
|
|
83
|
+
lines.append("")
|
|
84
|
+
covering = tasks_for_req.get(req.id, [])
|
|
85
|
+
if covering:
|
|
86
|
+
lines.append("### Covered by")
|
|
87
|
+
for tid in covering:
|
|
88
|
+
lines.append(f"- {_link(rel, f'tasks/{_slug(tid)}.md', tid)}")
|
|
89
|
+
docs.append(OKFDocument(
|
|
90
|
+
type="DevCouncil Requirement",
|
|
91
|
+
title=f"{req.id}: {req.title}",
|
|
92
|
+
description=req.description.strip()[:280],
|
|
93
|
+
tags=["requirement", req.priority, req.source],
|
|
94
|
+
timestamp=timestamp,
|
|
95
|
+
body="\n".join(lines).strip(),
|
|
96
|
+
rel_path=rel,
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
# --- Tasks ---
|
|
100
|
+
diffs_by_task: dict[str, list] = {}
|
|
101
|
+
for de in graph.diff_evidence:
|
|
102
|
+
diffs_by_task.setdefault(de.task_id, []).append(de)
|
|
103
|
+
gaps_by_task: dict[str, list] = {}
|
|
104
|
+
for gap in graph.gaps.values():
|
|
105
|
+
if gap.task_id:
|
|
106
|
+
gaps_by_task.setdefault(gap.task_id, []).append(gap)
|
|
107
|
+
|
|
108
|
+
for task in graph.tasks.values():
|
|
109
|
+
rel = f"tasks/{_slug(task.id)}.md"
|
|
110
|
+
lines = [task.description.strip(), ""]
|
|
111
|
+
if task.requirement_ids:
|
|
112
|
+
lines.append("### Implements")
|
|
113
|
+
for rid in task.requirement_ids:
|
|
114
|
+
label = rid
|
|
115
|
+
if rid in graph.requirements:
|
|
116
|
+
lines.append(f"- {_link(rel, f'requirements/{_slug(rid)}.md', label)}")
|
|
117
|
+
else:
|
|
118
|
+
lines.append(f"- {label}")
|
|
119
|
+
lines.append("")
|
|
120
|
+
if task.planned_files:
|
|
121
|
+
lines.append("### Planned files")
|
|
122
|
+
for pf in task.planned_files:
|
|
123
|
+
lines.append(f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}")
|
|
124
|
+
lines.append("")
|
|
125
|
+
if task.id in diffs_by_task:
|
|
126
|
+
lines.append("### Evidence")
|
|
127
|
+
for idx, _de in enumerate(diffs_by_task[task.id]):
|
|
128
|
+
ev_rel = f"evidence/{_slug(task.id)}-diff-{idx}.md"
|
|
129
|
+
lines.append(f"- {_link(rel, ev_rel, 'diff evidence')}")
|
|
130
|
+
lines.append("")
|
|
131
|
+
if task.id in gaps_by_task:
|
|
132
|
+
lines.append("### Open gaps")
|
|
133
|
+
for gap in gaps_by_task[task.id]:
|
|
134
|
+
lines.append(f"- {_link(rel, f'gaps/{_slug(gap.id)}.md', gap.id)}")
|
|
135
|
+
docs.append(OKFDocument(
|
|
136
|
+
type="DevCouncil Task",
|
|
137
|
+
title=f"{task.id}: {task.title}",
|
|
138
|
+
description=task.description.strip()[:280],
|
|
139
|
+
tags=["task", task.status],
|
|
140
|
+
timestamp=timestamp,
|
|
141
|
+
body="\n".join(lines).strip(),
|
|
142
|
+
rel_path=rel,
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
# --- Evidence (diff evidence as documents; resource points at the diff summary) ---
|
|
146
|
+
for task_id, evs in diffs_by_task.items():
|
|
147
|
+
for idx, de in enumerate(evs):
|
|
148
|
+
rel = f"evidence/{_slug(task_id)}-diff-{idx}.md"
|
|
149
|
+
body_lines = [de.diff_summary.strip(), ""]
|
|
150
|
+
if de.changed_files:
|
|
151
|
+
body_lines.append("**Changed files:**")
|
|
152
|
+
body_lines.extend(f"- `{f}`" for f in de.changed_files)
|
|
153
|
+
if task_id in graph.tasks:
|
|
154
|
+
body_lines.append("")
|
|
155
|
+
body_lines.append(f"Produced by {_link(rel, f'tasks/{_slug(task_id)}.md', task_id)}.")
|
|
156
|
+
docs.append(OKFDocument(
|
|
157
|
+
type="DevCouncil Evidence",
|
|
158
|
+
title=f"Diff evidence for {task_id}",
|
|
159
|
+
description=de.diff_summary.strip()[:280],
|
|
160
|
+
tags=["evidence", "diff"],
|
|
161
|
+
timestamp=timestamp,
|
|
162
|
+
body="\n".join(body_lines).strip(),
|
|
163
|
+
rel_path=rel,
|
|
164
|
+
))
|
|
165
|
+
|
|
166
|
+
# --- Gaps ---
|
|
167
|
+
for gap in graph.gaps.values():
|
|
168
|
+
rel = f"gaps/{_slug(gap.id)}.md"
|
|
169
|
+
lines = [gap.description.strip(), ""]
|
|
170
|
+
lines.append(f"**Recommended fix:** {gap.recommended_fix.strip()}")
|
|
171
|
+
lines.append("")
|
|
172
|
+
related = []
|
|
173
|
+
if gap.requirement_id and gap.requirement_id in graph.requirements:
|
|
174
|
+
related.append(_link(rel, f"requirements/{_slug(gap.requirement_id)}.md", gap.requirement_id))
|
|
175
|
+
if gap.task_id and gap.task_id in graph.tasks:
|
|
176
|
+
related.append(_link(rel, f"tasks/{_slug(gap.task_id)}.md", gap.task_id))
|
|
177
|
+
if related:
|
|
178
|
+
lines.append("**Related:** " + ", ".join(related))
|
|
179
|
+
docs.append(OKFDocument(
|
|
180
|
+
type="DevCouncil Gap",
|
|
181
|
+
title=f"{gap.id}: {gap.gap_type}",
|
|
182
|
+
description=gap.description.strip()[:280],
|
|
183
|
+
tags=["gap", gap.severity, "blocking" if gap.blocking else "non-blocking"],
|
|
184
|
+
timestamp=timestamp,
|
|
185
|
+
body="\n".join(lines).strip(),
|
|
186
|
+
rel_path=rel,
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
# --- Engineering skills (optional) ---
|
|
190
|
+
skill_docs = (
|
|
191
|
+
OKFBundleWriter._build_skill_docs(skills, timestamp)
|
|
192
|
+
if include_skills and skills
|
|
193
|
+
else []
|
|
194
|
+
)
|
|
195
|
+
docs.extend(skill_docs)
|
|
196
|
+
|
|
197
|
+
# --- Design system (optional) ---
|
|
198
|
+
design_doc = (
|
|
199
|
+
OKFBundleWriter._build_design_doc(design, timestamp)
|
|
200
|
+
if include_design and design is not None
|
|
201
|
+
else None
|
|
202
|
+
)
|
|
203
|
+
if design_doc is not None:
|
|
204
|
+
docs.append(design_doc)
|
|
205
|
+
|
|
206
|
+
# --- index.md hierarchy ---
|
|
207
|
+
docs.extend(
|
|
208
|
+
OKFBundleWriter._build_indexes(graph, project_name, timestamp, skill_docs, design_doc)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return OKFBundle(documents=docs)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _build_design_doc(design, timestamp: str) -> OKFDocument:
|
|
215
|
+
"""Render a design system as an OKF document under ``design/``.
|
|
216
|
+
|
|
217
|
+
Conversion goes through :func:`design.design_system_to_okf_document` so the bundle
|
|
218
|
+
and any future design ingest share one DesignSystem<->OKF mapping. The bundle-level
|
|
219
|
+
``timestamp`` is stamped on (the renderer itself leaves it empty as library content).
|
|
220
|
+
"""
|
|
221
|
+
from devcouncil.knowledge.design import design_system_to_okf_document
|
|
222
|
+
|
|
223
|
+
doc = design_system_to_okf_document(design)
|
|
224
|
+
if timestamp:
|
|
225
|
+
doc = doc.model_copy(update={"timestamp": timestamp})
|
|
226
|
+
return doc
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _build_skill_docs(skills: "list", timestamp: str) -> list[OKFDocument]:
|
|
230
|
+
"""Render engineering skills as OKF documents under ``skills/``.
|
|
231
|
+
|
|
232
|
+
Conversion goes through :func:`skill_bridge.skill_to_okf_document` so the export
|
|
233
|
+
side and the ingest side share one Skill<->OKF mapping and can't drift. The
|
|
234
|
+
bundle-level ``timestamp`` is stamped on so skill nodes carry the same export time
|
|
235
|
+
as the rest of the bundle (the bridge itself leaves it empty as library content).
|
|
236
|
+
"""
|
|
237
|
+
from devcouncil.knowledge.skill_bridge import skill_to_okf_document
|
|
238
|
+
|
|
239
|
+
out: list[OKFDocument] = []
|
|
240
|
+
for skill in skills:
|
|
241
|
+
doc = skill_to_okf_document(skill)
|
|
242
|
+
if timestamp:
|
|
243
|
+
doc = doc.model_copy(update={"timestamp": timestamp})
|
|
244
|
+
out.append(doc)
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _build_indexes(
|
|
249
|
+
graph: ArtifactGraph,
|
|
250
|
+
project_name: str,
|
|
251
|
+
timestamp: str,
|
|
252
|
+
skill_docs: "list[OKFDocument] | None" = None,
|
|
253
|
+
design_doc: "OKFDocument | None" = None,
|
|
254
|
+
) -> list[OKFDocument]:
|
|
255
|
+
indexes: list[OKFDocument] = []
|
|
256
|
+
summary = graph.coverage_summary()
|
|
257
|
+
skill_docs = skill_docs or []
|
|
258
|
+
|
|
259
|
+
def section_index(folder: str, title: str, items: list[tuple[str, str]]) -> None:
|
|
260
|
+
rel = f"{folder}/index.md"
|
|
261
|
+
lines = [f"{len(items)} {title.lower()}.", ""]
|
|
262
|
+
for item_id, item_title in items:
|
|
263
|
+
lines.append(f"- {_link(rel, f'{folder}/{_slug(item_id)}.md', item_title)}")
|
|
264
|
+
indexes.append(OKFDocument(
|
|
265
|
+
type="OKF Index",
|
|
266
|
+
title=title,
|
|
267
|
+
description=f"{len(items)} {title.lower()} exported from DevCouncil.",
|
|
268
|
+
timestamp=timestamp,
|
|
269
|
+
body="\n".join(lines).strip(),
|
|
270
|
+
rel_path=rel,
|
|
271
|
+
))
|
|
272
|
+
|
|
273
|
+
section_index("requirements", "Requirements",
|
|
274
|
+
[(r.id, f"{r.id}: {r.title}") for r in graph.requirements.values()])
|
|
275
|
+
section_index("tasks", "Tasks",
|
|
276
|
+
[(t.id, f"{t.id}: {t.title}") for t in graph.tasks.values()])
|
|
277
|
+
section_index("gaps", "Gaps",
|
|
278
|
+
[(g.id, f"{g.id}: {g.gap_type}") for g in graph.gaps.values()])
|
|
279
|
+
|
|
280
|
+
# Skills index — links to each skill document so they join the connected graph.
|
|
281
|
+
if skill_docs:
|
|
282
|
+
rel = "skills/index.md"
|
|
283
|
+
lines = [f"{len(skill_docs)} engineering skills.", ""]
|
|
284
|
+
for doc in skill_docs:
|
|
285
|
+
lines.append(f"- {_link(rel, doc.rel_path, doc.title or doc.rel_path)}")
|
|
286
|
+
indexes.append(OKFDocument(
|
|
287
|
+
type="OKF Index",
|
|
288
|
+
title="Skills",
|
|
289
|
+
description=f"{len(skill_docs)} engineering skills exported from DevCouncil.",
|
|
290
|
+
timestamp=timestamp,
|
|
291
|
+
body="\n".join(lines).strip(),
|
|
292
|
+
rel_path=rel,
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
# Design index — links to the single design document so it joins the graph.
|
|
296
|
+
if design_doc is not None:
|
|
297
|
+
rel = "design/index.md"
|
|
298
|
+
lines = [
|
|
299
|
+
"1 design system.",
|
|
300
|
+
"",
|
|
301
|
+
f"- {_link(rel, design_doc.rel_path, design_doc.title or design_doc.rel_path)}",
|
|
302
|
+
]
|
|
303
|
+
indexes.append(OKFDocument(
|
|
304
|
+
type="OKF Index",
|
|
305
|
+
title="Design System",
|
|
306
|
+
description="Design system exported from DevCouncil.",
|
|
307
|
+
timestamp=timestamp,
|
|
308
|
+
body="\n".join(lines).strip(),
|
|
309
|
+
rel_path=rel,
|
|
310
|
+
))
|
|
311
|
+
|
|
312
|
+
# Root index links to each section index.
|
|
313
|
+
root_lines = [
|
|
314
|
+
f"Knowledge bundle exported from **{project_name}** in Open Knowledge Format.",
|
|
315
|
+
"",
|
|
316
|
+
"### Coverage",
|
|
317
|
+
f"- Requirements: {summary['total_requirements']}",
|
|
318
|
+
f"- Tasks: {summary['total_tasks']}",
|
|
319
|
+
f"- Acceptance criteria: {summary['total_ac']} ({summary['ac_without_evidence']} without evidence)",
|
|
320
|
+
f"- Gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking)",
|
|
321
|
+
"",
|
|
322
|
+
"### Sections",
|
|
323
|
+
"- [Requirements](requirements/index.md)",
|
|
324
|
+
"- [Tasks](tasks/index.md)",
|
|
325
|
+
"- [Gaps](gaps/index.md)",
|
|
326
|
+
]
|
|
327
|
+
if skill_docs:
|
|
328
|
+
root_lines.append("- [Skills](skills/index.md)")
|
|
329
|
+
if design_doc is not None:
|
|
330
|
+
root_lines.append("- [Design System](design/index.md)")
|
|
331
|
+
indexes.append(OKFDocument(
|
|
332
|
+
type="OKF Index",
|
|
333
|
+
title=project_name,
|
|
334
|
+
description="DevCouncil artifact graph exported as an OKF knowledge bundle.",
|
|
335
|
+
timestamp=timestamp,
|
|
336
|
+
body="\n".join(root_lines).strip(),
|
|
337
|
+
rel_path="index.md",
|
|
338
|
+
))
|
|
339
|
+
return indexes
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def generate(
|
|
343
|
+
graph: ArtifactGraph,
|
|
344
|
+
output_dir: Path,
|
|
345
|
+
repo_map: RepoMap | None = None,
|
|
346
|
+
project_name: str = "DevCouncil Project",
|
|
347
|
+
timestamp: str = "",
|
|
348
|
+
include_skills: bool = False,
|
|
349
|
+
skills: "list | None" = None,
|
|
350
|
+
include_design: bool = False,
|
|
351
|
+
design=None,
|
|
352
|
+
) -> list[Path]:
|
|
353
|
+
"""Build the bundle and write it under ``output_dir``; returns written paths."""
|
|
354
|
+
bundle = OKFBundleWriter.build(
|
|
355
|
+
graph,
|
|
356
|
+
repo_map,
|
|
357
|
+
project_name,
|
|
358
|
+
timestamp,
|
|
359
|
+
include_skills=include_skills,
|
|
360
|
+
skills=skills,
|
|
361
|
+
include_design=include_design,
|
|
362
|
+
design=design,
|
|
363
|
+
)
|
|
364
|
+
return write_bundle(bundle, output_dir)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Render an OKF bundle as a self-contained, browsable static HTML site.
|
|
2
|
+
|
|
3
|
+
Parity with the upstream OKF HTML visualizer: a directory of markdown nodes becomes a
|
|
4
|
+
directory of HTML pages — one ``index.html`` listing every document grouped by OKF
|
|
5
|
+
``type``, plus one page per document showing its frontmatter header and rendered body.
|
|
6
|
+
Intra-bundle markdown links (the relative ``*.md`` targets that
|
|
7
|
+
:func:`devcouncil.knowledge.okf.read_bundle` resolves into :attr:`OKFDocument.links`) are
|
|
8
|
+
rewritten to point at the generated ``*.html`` pages so navigation works offline; external
|
|
9
|
+
URLs and the ``resource`` field are preserved as-is.
|
|
10
|
+
|
|
11
|
+
Security: every string taken from the bundle — titles, descriptions, tags, body text,
|
|
12
|
+
code, link labels, and link targets — is HTML-escaped, and link hrefs are scheme-checked
|
|
13
|
+
so a hostile or malformed bundle cannot inject markup or a ``javascript:`` href. Only the
|
|
14
|
+
structural HTML this module emits is literal. The output is one inlined ``<style>`` block
|
|
15
|
+
with no external assets, no network calls, and no JavaScript.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import html
|
|
21
|
+
import posixpath
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from devcouncil.knowledge.okf import OKFBundle, OKFDocument, _resolve_link
|
|
26
|
+
|
|
27
|
+
# Inline markdown constructs. Applied in a deliberate order (code, then links, then
|
|
28
|
+
# bold/italic on already-escaped text) via placeholder stashing so escaping never mangles
|
|
29
|
+
# generated tags and generated tags never get re-parsed.
|
|
30
|
+
_CODE_RE = re.compile(r"`([^`]+)`")
|
|
31
|
+
_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
32
|
+
_BOLD_RE = re.compile(r"\*\*([^*]+)\*\*")
|
|
33
|
+
_ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)([^*]+)\*(?!\*)")
|
|
34
|
+
_PLACEHOLDER_RE = re.compile(r"__PH_(\d+)__")
|
|
35
|
+
_TABLE_SEP_RE = re.compile(r"\s*\|?[\s:|-]*-[\s:|-]*\|?\s*")
|
|
36
|
+
|
|
37
|
+
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
|
|
38
|
+
_BAD_SCHEME_RE = re.compile(r"^(?:javascript|data|vbscript):", re.IGNORECASE)
|
|
39
|
+
# Whitespace + C0 control chars. Browsers strip these from a URL before resolving its
|
|
40
|
+
# scheme, so they must be removed before the scheme check — otherwise "java	script:"
|
|
41
|
+
# or " javascript:" slips past the guard yet still executes once the browser reassembles it.
|
|
42
|
+
_SCHEME_NOISE_RE = re.compile(r"[\x00-\x20]+")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_dangerous_scheme(target: str) -> bool:
|
|
46
|
+
"""True if ``target`` carries a dangerous URL scheme, even when obfuscated with leading
|
|
47
|
+
or interior whitespace / control characters (e.g. ``java\\tscript:``, `` javascript:``)."""
|
|
48
|
+
return bool(_BAD_SCHEME_RE.match(_SCHEME_NOISE_RE.sub("", target)))
|
|
49
|
+
|
|
50
|
+
_STYLE = """
|
|
51
|
+
body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
52
|
+
max-width:52rem;margin:2rem auto;padding:0 1rem;color:#1a1a1a;background:#fff}
|
|
53
|
+
h1,h2,h3,h4{line-height:1.25}a{color:#0b66c3}code{background:#f3f3f3;padding:.1em .3em;
|
|
54
|
+
border-radius:3px;font-size:.9em}pre{background:#f6f8fa;padding:1rem;border-radius:6px;
|
|
55
|
+
overflow:auto}pre code{background:none;padding:0}table{border-collapse:collapse;width:100%}
|
|
56
|
+
th,td{border:1px solid #ddd;padding:.4rem .6rem;text-align:left}
|
|
57
|
+
.meta{color:#555;font-size:.9em}.tag{display:inline-block;background:#eef3fb;color:#0b66c3;
|
|
58
|
+
border-radius:10px;padding:.05em .6em;margin:.1em .2em .1em 0;font-size:.8em}
|
|
59
|
+
.doc-list{list-style:none;padding:0}.doc-list li{margin:.4rem 0}
|
|
60
|
+
.type-group{margin:1.4rem 0}.crumb{font-size:.9em;margin-bottom:1rem}
|
|
61
|
+
""".strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _md_to_html_rel(rel_md: str) -> str:
|
|
65
|
+
"""Map a bundle-relative ``*.md`` path to its generated ``*.html`` page path."""
|
|
66
|
+
return rel_md[:-3] + ".html" if rel_md.endswith(".md") else rel_md + ".html"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _rel_href(src_html: str, dst_html: str) -> str:
|
|
70
|
+
"""Relative href from one generated page to another (POSIX, URL-style)."""
|
|
71
|
+
src_dir = posixpath.dirname(src_html)
|
|
72
|
+
return posixpath.relpath(dst_html, src_dir or ".")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _safe_external(target: str) -> str:
|
|
76
|
+
"""Escape an external/anchor link target, neutralizing dangerous schemes to ``#``."""
|
|
77
|
+
if _is_dangerous_scheme(target):
|
|
78
|
+
return "#"
|
|
79
|
+
return html.escape(target.strip(), quote=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rewrite_link(target: str, current_md_rel: str, present: set[str]) -> str:
|
|
83
|
+
"""Resolve a markdown link target into an escaped, safe href for the current page.
|
|
84
|
+
|
|
85
|
+
Intra-bundle ``*.md`` targets that resolve to a document present in the bundle become a
|
|
86
|
+
relative link to the generated ``*.html`` page (anchors preserved); everything else
|
|
87
|
+
(external URLs, mailto, in-page anchors, unresolved targets) is escaped and scheme-
|
|
88
|
+
checked but otherwise left to behave as a normal link.
|
|
89
|
+
"""
|
|
90
|
+
t = target.strip()
|
|
91
|
+
if not t or _is_dangerous_scheme(t):
|
|
92
|
+
return "#"
|
|
93
|
+
if t.startswith("#") or t.startswith("mailto:") or _URL_SCHEME_RE.match(t):
|
|
94
|
+
return _safe_external(t)
|
|
95
|
+
base, sep, anchor = t.partition("#")
|
|
96
|
+
resolved = _resolve_link(current_md_rel, base)
|
|
97
|
+
if resolved and resolved in present:
|
|
98
|
+
dst = _md_to_html_rel(resolved)
|
|
99
|
+
href = _rel_href(_md_to_html_rel(current_md_rel), dst)
|
|
100
|
+
if sep:
|
|
101
|
+
href = f"{href}#{anchor}"
|
|
102
|
+
return html.escape(href, quote=True)
|
|
103
|
+
return _safe_external(t)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _render_inline(text: str, current_md_rel: str, present: set[str]) -> str:
|
|
107
|
+
"""Render inline markdown (code, links, bold, italic) with everything escaped."""
|
|
108
|
+
stash: list[str] = []
|
|
109
|
+
|
|
110
|
+
def _stash(fragment: str) -> str:
|
|
111
|
+
stash.append(fragment)
|
|
112
|
+
return f"__PH_{len(stash) - 1}__"
|
|
113
|
+
|
|
114
|
+
text = _CODE_RE.sub(lambda m: _stash(f"<code>{html.escape(m.group(1))}</code>"), text)
|
|
115
|
+
text = _LINK_RE.sub(
|
|
116
|
+
lambda m: _stash(
|
|
117
|
+
f'<a href="{_rewrite_link(m.group(2), current_md_rel, present)}">'
|
|
118
|
+
f"{html.escape(m.group(1))}</a>"
|
|
119
|
+
),
|
|
120
|
+
text,
|
|
121
|
+
)
|
|
122
|
+
text = html.escape(text)
|
|
123
|
+
text = _BOLD_RE.sub(r"<strong>\1</strong>", text)
|
|
124
|
+
text = _ITALIC_RE.sub(r"<em>\1</em>", text)
|
|
125
|
+
return _PLACEHOLDER_RE.sub(lambda m: stash[int(m.group(1))], text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_table(rows: list[str], current_md_rel: str, present: set[str]) -> str:
|
|
129
|
+
"""Render a GitHub-style pipe table (header row + ``---`` separator + body rows)."""
|
|
130
|
+
def cells(line: str) -> list[str]:
|
|
131
|
+
return [c.strip() for c in line.strip().strip("|").split("|")]
|
|
132
|
+
|
|
133
|
+
header = cells(rows[0])
|
|
134
|
+
body = [cells(r) for r in rows[2:]]
|
|
135
|
+
head_html = "".join(f"<th>{_render_inline(c, current_md_rel, present)}</th>" for c in header)
|
|
136
|
+
body_html = "".join(
|
|
137
|
+
"<tr>" + "".join(f"<td>{_render_inline(c, current_md_rel, present)}</td>" for c in r) + "</tr>"
|
|
138
|
+
for r in body
|
|
139
|
+
)
|
|
140
|
+
return f"<table><thead><tr>{head_html}</tr></thead><tbody>{body_html}</tbody></table>"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_table_sep(line: str) -> bool:
|
|
144
|
+
return bool(_TABLE_SEP_RE.fullmatch(line)) and "-" in line
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def render_markdown(body: str, current_md_rel: str, present: set[str]) -> str:
|
|
148
|
+
"""Convert an OKF document body to HTML (minimal, dependency-free, fully escaped)."""
|
|
149
|
+
lines = body.splitlines()
|
|
150
|
+
out: list[str] = []
|
|
151
|
+
i, n = 0, len(lines)
|
|
152
|
+
para: list[str] = []
|
|
153
|
+
list_items: list[str] = []
|
|
154
|
+
list_tag = ""
|
|
155
|
+
|
|
156
|
+
def flush_para() -> None:
|
|
157
|
+
if para:
|
|
158
|
+
out.append(f"<p>{_render_inline(' '.join(para), current_md_rel, present)}</p>")
|
|
159
|
+
para.clear()
|
|
160
|
+
|
|
161
|
+
def flush_list() -> None:
|
|
162
|
+
nonlocal list_tag
|
|
163
|
+
if list_items:
|
|
164
|
+
inner = "".join(f"<li>{it}</li>" for it in list_items)
|
|
165
|
+
out.append(f"<{list_tag}>{inner}</{list_tag}>")
|
|
166
|
+
list_items.clear()
|
|
167
|
+
list_tag = ""
|
|
168
|
+
|
|
169
|
+
while i < n:
|
|
170
|
+
line = lines[i]
|
|
171
|
+
stripped = line.strip()
|
|
172
|
+
|
|
173
|
+
if stripped.startswith("```"): # fenced code block
|
|
174
|
+
flush_para()
|
|
175
|
+
flush_list()
|
|
176
|
+
i += 1
|
|
177
|
+
code: list[str] = []
|
|
178
|
+
while i < n and not lines[i].strip().startswith("```"):
|
|
179
|
+
code.append(lines[i])
|
|
180
|
+
i += 1
|
|
181
|
+
i += 1 # consume closing fence
|
|
182
|
+
out.append(f"<pre><code>{html.escape(chr(10).join(code))}</code></pre>")
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if not stripped: # blank line
|
|
186
|
+
flush_para()
|
|
187
|
+
flush_list()
|
|
188
|
+
i += 1
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
|
192
|
+
if heading:
|
|
193
|
+
flush_para()
|
|
194
|
+
flush_list()
|
|
195
|
+
level = len(heading.group(1))
|
|
196
|
+
out.append(f"<h{level}>{_render_inline(heading.group(2), current_md_rel, present)}</h{level}>")
|
|
197
|
+
i += 1
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if re.fullmatch(r"(?:-\s*){3,}|(?:\*\s*){3,}|(?:_\s*){3,}", stripped):
|
|
201
|
+
flush_para()
|
|
202
|
+
flush_list()
|
|
203
|
+
out.append("<hr>")
|
|
204
|
+
i += 1
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
if "|" in line and i + 1 < n and _is_table_sep(lines[i + 1]):
|
|
208
|
+
flush_para()
|
|
209
|
+
flush_list()
|
|
210
|
+
table_rows = [line, lines[i + 1]]
|
|
211
|
+
i += 2
|
|
212
|
+
while i < n and "|" in lines[i] and lines[i].strip():
|
|
213
|
+
table_rows.append(lines[i])
|
|
214
|
+
i += 1
|
|
215
|
+
out.append(_render_table(table_rows, current_md_rel, present))
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
ul = re.match(r"^[-*+]\s+(.*)$", stripped)
|
|
219
|
+
ol = re.match(r"^\d+[.)]\s+(.*)$", stripped)
|
|
220
|
+
if ul or ol:
|
|
221
|
+
flush_para()
|
|
222
|
+
tag = "ul" if ul else "ol"
|
|
223
|
+
if list_tag and list_tag != tag:
|
|
224
|
+
flush_list()
|
|
225
|
+
list_tag = tag
|
|
226
|
+
match = ul or ol # one is non-None inside this branch
|
|
227
|
+
content = match.group(1) if match else ""
|
|
228
|
+
list_items.append(_render_inline(content, current_md_rel, present))
|
|
229
|
+
i += 1
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
flush_list()
|
|
233
|
+
para.append(stripped)
|
|
234
|
+
i += 1
|
|
235
|
+
|
|
236
|
+
flush_para()
|
|
237
|
+
flush_list()
|
|
238
|
+
return "\n".join(out)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _page(title: str, breadcrumb: str, content: str) -> str:
|
|
242
|
+
"""Wrap rendered content in a complete, self-contained HTML document."""
|
|
243
|
+
return (
|
|
244
|
+
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
|
|
245
|
+
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
|
246
|
+
f"<title>{html.escape(title)}</title>\n<style>{_STYLE}</style>\n</head>\n<body>\n"
|
|
247
|
+
f"{breadcrumb}{content}\n</body>\n</html>\n"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _doc_header(doc: OKFDocument) -> str:
|
|
252
|
+
"""The frontmatter block (type, title, description, resource, tags, timestamp)."""
|
|
253
|
+
parts = [f"<h1>{html.escape(doc.title or doc.rel_path or doc.type)}</h1>"]
|
|
254
|
+
meta: list[str] = []
|
|
255
|
+
if doc.type:
|
|
256
|
+
meta.append(f"<strong>{html.escape(doc.type)}</strong>")
|
|
257
|
+
if doc.timestamp:
|
|
258
|
+
meta.append(html.escape(doc.timestamp))
|
|
259
|
+
if meta:
|
|
260
|
+
parts.append(f'<p class="meta">{" · ".join(meta)}</p>')
|
|
261
|
+
if doc.description:
|
|
262
|
+
parts.append(f"<p>{html.escape(doc.description)}</p>")
|
|
263
|
+
if doc.resource:
|
|
264
|
+
href = _safe_external(doc.resource)
|
|
265
|
+
parts.append(f'<p class="meta">Resource: <a href="{href}">{html.escape(doc.resource)}</a></p>')
|
|
266
|
+
if doc.tags:
|
|
267
|
+
tags = "".join(f'<span class="tag">{html.escape(str(t))}</span>' for t in doc.tags)
|
|
268
|
+
parts.append(f"<p>{tags}</p>")
|
|
269
|
+
return "\n".join(parts)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def render_bundle_html(bundle: OKFBundle) -> dict[str, str]:
|
|
273
|
+
"""Render ``bundle`` into a map of ``{relative_html_path: html_string}``.
|
|
274
|
+
|
|
275
|
+
Always includes ``index.html``; documents with a ``rel_path`` each get a page at the
|
|
276
|
+
same path with a ``.html`` extension. Documents without a ``rel_path`` are skipped
|
|
277
|
+
(they have no stable node identity to link to), matching :func:`write_bundle`.
|
|
278
|
+
"""
|
|
279
|
+
present = set(bundle.by_path().keys())
|
|
280
|
+
pages: dict[str, str] = {}
|
|
281
|
+
|
|
282
|
+
# Per-document pages.
|
|
283
|
+
by_type: dict[str, list[OKFDocument]] = {}
|
|
284
|
+
for doc in bundle.documents:
|
|
285
|
+
if not doc.rel_path:
|
|
286
|
+
continue
|
|
287
|
+
by_type.setdefault(doc.type or "Untyped", []).append(doc)
|
|
288
|
+
page_rel = _md_to_html_rel(doc.rel_path)
|
|
289
|
+
depth = page_rel.count("/")
|
|
290
|
+
crumb = f'<p class="crumb"><a href="{"../" * depth}index.html">← Index</a></p>'
|
|
291
|
+
content = _doc_header(doc) + "\n" + render_markdown(doc.body, doc.rel_path, present)
|
|
292
|
+
pages[page_rel] = _page(doc.title or doc.rel_path, crumb, content)
|
|
293
|
+
|
|
294
|
+
# Index, grouped by type.
|
|
295
|
+
groups: list[str] = []
|
|
296
|
+
for type_name in sorted(by_type):
|
|
297
|
+
docs = sorted(by_type[type_name], key=lambda d: (d.title or d.rel_path).lower())
|
|
298
|
+
items: list[str] = []
|
|
299
|
+
for doc in docs:
|
|
300
|
+
href = html.escape(_rel_href("index.html", _md_to_html_rel(doc.rel_path)), quote=True)
|
|
301
|
+
label = html.escape(doc.title or doc.rel_path)
|
|
302
|
+
desc = f" — {html.escape(doc.description)}" if doc.description else ""
|
|
303
|
+
items.append(f'<li><a href="{href}">{label}</a><span class="meta">{desc}</span></li>')
|
|
304
|
+
groups.append(
|
|
305
|
+
f'<div class="type-group"><h2>{html.escape(type_name)} '
|
|
306
|
+
f'<span class="meta">({len(docs)})</span></h2>'
|
|
307
|
+
f'<ul class="doc-list">{"".join(items)}</ul></div>'
|
|
308
|
+
)
|
|
309
|
+
body = f"<h1>OKF Bundle</h1><p class=\"meta\">{len(present)} document(s)</p>" + "".join(groups)
|
|
310
|
+
pages["index.html"] = _page("OKF Bundle", "", body)
|
|
311
|
+
return pages
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def write_bundle_html(bundle: OKFBundle, out_dir: Path) -> list[Path]:
|
|
315
|
+
"""Render ``bundle`` and write the static site under ``out_dir``; return written paths."""
|
|
316
|
+
out_dir = out_dir.expanduser().resolve()
|
|
317
|
+
written: list[Path] = []
|
|
318
|
+
for rel, content in render_bundle_html(bundle).items():
|
|
319
|
+
target = out_dir / rel
|
|
320
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
target.write_text(content, encoding="utf-8")
|
|
322
|
+
written.append(target)
|
|
323
|
+
return written
|