Skip to main content

vela_protocol/
doc_render.rs

1//! v0.110: static HTML site generator for a frontier.
2//!
3//! Self-contained: no JS framework, no external dependencies, no
4//! build step. `vela doc <path>` writes a small set of HTML files
5//! plus a single CSS file to an output directory; the result is
6//! browseable from disk in any browser. Cargo's docs.rs analog
7//! for scientific state.
8//!
9//! Output structure:
10//!
11//! ```text
12//! <out>/
13//!   index.html          frontier overview
14//!   findings.html       table of every finding
15//!   findings/<vf_id>.html   per-finding detail pages
16//!   events.html         table of every canonical event
17//!   style.css           minimal styling
18//! ```
19//!
20//! No DSL, no dynamic rendering. Plain HTML written by `format!`.
21
22use std::path::Path;
23
24use crate::project::Project;
25
26/// Result returned to the CLI: counts and the output root path.
27#[derive(Debug, Clone, serde::Serialize)]
28pub struct DocReport {
29    pub command: String,
30    pub ok: bool,
31    pub frontier_id: String,
32    pub out: String,
33    pub files_written: usize,
34    pub findings_documented: usize,
35    pub events_documented: usize,
36}
37
38/// Write the static documentation site for `project` into
39/// `out_dir`. Creates the directory tree if absent.
40pub fn write_site(project: &Project, out_dir: &Path) -> Result<DocReport, String> {
41    std::fs::create_dir_all(out_dir).map_err(|e| format!("create out dir: {e}"))?;
42    let findings_dir = out_dir.join("findings");
43    std::fs::create_dir_all(&findings_dir).map_err(|e| format!("create findings dir: {e}"))?;
44
45    let mut files_written = 0usize;
46
47    write_file(&out_dir.join("style.css"), &css())?;
48    files_written += 1;
49
50    write_file(&out_dir.join("index.html"), &render_index(project))?;
51    files_written += 1;
52
53    write_file(
54        &out_dir.join("findings.html"),
55        &render_findings_index(project),
56    )?;
57    files_written += 1;
58
59    for finding in &project.findings {
60        let path = findings_dir.join(format!("{}.html", finding.id));
61        write_file(&path, &render_finding_detail(project, finding))?;
62        files_written += 1;
63    }
64
65    write_file(&out_dir.join("events.html"), &render_events(project))?;
66    files_written += 1;
67
68    Ok(DocReport {
69        command: "doc".to_string(),
70        ok: true,
71        frontier_id: project
72            .frontier_id
73            .clone()
74            .unwrap_or_else(|| "(unset)".to_string()),
75        out: out_dir.display().to_string(),
76        files_written,
77        findings_documented: project.findings.len(),
78        events_documented: project.events.len(),
79    })
80}
81
82fn write_file(path: &Path, contents: &str) -> Result<(), String> {
83    std::fs::write(path, contents).map_err(|e| format!("write {}: {e}", path.display()))
84}
85
86/// HTML-escape a string. Conservative: encodes `<`, `>`, `&`, `"`,
87/// `'`. Used for every user-supplied string that lands in HTML.
88fn esc(s: &str) -> String {
89    let mut out = String::with_capacity(s.len());
90    for c in s.chars() {
91        match c {
92            '&' => out.push_str("&amp;"),
93            '<' => out.push_str("&lt;"),
94            '>' => out.push_str("&gt;"),
95            '"' => out.push_str("&quot;"),
96            '\'' => out.push_str("&#39;"),
97            _ => out.push(c),
98        }
99    }
100    out
101}
102
103/// Truncate a string to roughly `n` chars, appending an ellipsis
104/// when truncation occurred.
105fn truncate(s: &str, n: usize) -> String {
106    if s.chars().count() <= n {
107        return s.to_string();
108    }
109    let mut out: String = s.chars().take(n).collect();
110    out.push('…');
111    out
112}
113
114fn page_shell(title: &str, body: &str, breadcrumbs: &str) -> String {
115    format!(
116        "<!doctype html>
117<html lang=\"en\">
118<head>
119<meta charset=\"utf-8\">
120<title>{title}</title>
121<link rel=\"stylesheet\" href=\"{stylesheet}\">
122</head>
123<body>
124<header>
125<nav>{breadcrumbs}</nav>
126</header>
127<main>
128{body}
129</main>
130<footer>
131<p>generated by <code>vela doc</code></p>
132</footer>
133</body>
134</html>",
135        title = esc(title),
136        stylesheet = if breadcrumbs.contains("href=\"../") {
137            "../style.css"
138        } else {
139            "style.css"
140        },
141        breadcrumbs = breadcrumbs,
142        body = body,
143    )
144}
145
146fn render_index(project: &Project) -> String {
147    let frontier_id = project
148        .frontier_id
149        .clone()
150        .unwrap_or_else(|| "(unset)".to_string());
151    let title = format!("{} · vela frontier", project.project.name);
152    let body = format!(
153        "<h1>{name}</h1>
154<p class=\"id\">{frontier_id}</p>
155<p class=\"description\">{description}</p>
156
157<section>
158<h2>Counts</h2>
159<table class=\"counts\">
160<tr><th>findings</th><td>{n_findings}</td></tr>
161<tr><th>events</th><td>{n_events}</td></tr>
162<tr><th>artifacts</th><td>{n_artifacts}</td></tr>
163<tr><th>sources</th><td>{n_sources}</td></tr>
164<tr><th>actors</th><td>{n_actors}</td></tr>
165<tr><th>signatures</th><td>{n_signatures}</td></tr>
166<tr><th>replications</th><td>{n_replications}</td></tr>
167<tr><th>predictions</th><td>{n_predictions}</td></tr>
168</table>
169</section>
170
171<section>
172<h2>Pages</h2>
173<ul>
174<li><a href=\"findings.html\">Findings</a></li>
175<li><a href=\"events.html\">Events</a></li>
176</ul>
177</section>",
178        name = esc(&project.project.name),
179        frontier_id = esc(&frontier_id),
180        description = esc(&project.project.description),
181        n_findings = project.findings.len(),
182        n_events = project.events.len(),
183        n_artifacts = project.artifacts.len(),
184        n_sources = project.sources.len(),
185        n_actors = project.actors.len(),
186        n_signatures = project.signatures.len(),
187        n_replications = project.replications.len(),
188        n_predictions = project.predictions.len(),
189    );
190    page_shell(&title, &body, "<a href=\"index.html\">home</a>")
191}
192
193fn render_findings_index(project: &Project) -> String {
194    let title = format!("{} · findings", project.project.name);
195    let mut rows = String::new();
196    for finding in &project.findings {
197        rows.push_str(&format!(
198            "<tr><td><a href=\"findings/{id}.html\"><code>{id}</code></a></td>
199<td>{kind}</td>
200<td>{status}</td>
201<td>{conf}</td>
202<td>{assertion}</td></tr>",
203            id = esc(&finding.id),
204            kind = esc(&finding.assertion.assertion_type),
205            status = esc(finding
206                .flags
207                .review_state
208                .as_ref()
209                .map(|s| match s {
210                    crate::bundle::ReviewState::Accepted => "accepted",
211                    crate::bundle::ReviewState::Contested => "contested",
212                    crate::bundle::ReviewState::NeedsRevision => "needs_revision",
213                    crate::bundle::ReviewState::Rejected => "rejected",
214                })
215                .unwrap_or("none")),
216            conf = format_args!("{:.2}", finding.confidence.score),
217            assertion = esc(&truncate(&finding.assertion.text, 140)),
218        ));
219    }
220    let body = format!(
221        "<h1>Findings</h1>
222<p>{n} findings on this frontier.</p>
223<table class=\"findings\">
224<thead><tr><th>id</th><th>type</th><th>status</th><th>confidence</th><th>assertion</th></tr></thead>
225<tbody>{rows}</tbody>
226</table>",
227        n = project.findings.len(),
228        rows = rows,
229    );
230    page_shell(
231        &title,
232        &body,
233        "<a href=\"index.html\">home</a> &rsaquo; findings",
234    )
235}
236
237fn render_finding_detail(project: &Project, finding: &crate::bundle::FindingBundle) -> String {
238    let title = format!("{} · {}", finding.id, project.project.name);
239    let related: Vec<&crate::events::StateEvent> = project
240        .events
241        .iter()
242        .filter(|e| e.target.id == finding.id)
243        .collect();
244    let mut events_rows = String::new();
245    for e in &related {
246        events_rows.push_str(&format!(
247            "<tr><td><code>{id}</code></td><td>{kind}</td><td>{actor}</td><td>{ts}</td><td>{reason}</td></tr>",
248            id = esc(&e.id),
249            kind = esc(&e.kind),
250            actor = esc(&e.actor.id),
251            ts = esc(&e.timestamp),
252            reason = esc(&truncate(&e.reason, 120)),
253        ));
254    }
255    let signature_count = project
256        .signatures
257        .iter()
258        .filter(|s| s.finding_id == finding.id)
259        .count();
260    let body = format!(
261        "<h1>{id}</h1>
262<p class=\"assertion\">{assertion}</p>
263
264<section>
265<h2>Metadata</h2>
266<table class=\"metadata\">
267<tr><th>type</th><td>{kind}</td></tr>
268<tr><th>status</th><td>{status}</td></tr>
269<tr><th>confidence</th><td>{conf}</td></tr>
270<tr><th>basis</th><td>{basis}</td></tr>
271<tr><th>retracted</th><td>{retracted}</td></tr>
272<tr><th>contested</th><td>{contested}</td></tr>
273<tr><th>signatures</th><td>{n_sigs}</td></tr>
274<tr><th>annotations</th><td>{n_ann}</td></tr>
275</table>
276</section>
277
278<section>
279<h2>Provenance</h2>
280<table class=\"provenance\">
281<tr><th>source type</th><td>{src_type}</td></tr>
282<tr><th>doi</th><td>{doi}</td></tr>
283<tr><th>pmid</th><td>{pmid}</td></tr>
284<tr><th>year</th><td>{year}</td></tr>
285<tr><th>journal</th><td>{journal}</td></tr>
286</table>
287</section>
288
289<section>
290<h2>Events targeting this finding</h2>
291<table class=\"events\">
292<thead><tr><th>id</th><th>kind</th><th>actor</th><th>timestamp</th><th>reason</th></tr></thead>
293<tbody>{events_rows}</tbody>
294</table>
295<p>{n_events} event(s).</p>
296</section>",
297        id = esc(&finding.id),
298        assertion = esc(&finding.assertion.text),
299        kind = esc(&finding.assertion.assertion_type),
300        status = esc(finding
301            .flags
302            .review_state
303            .as_ref()
304            .map(|s| match s {
305                crate::bundle::ReviewState::Accepted => "accepted",
306                crate::bundle::ReviewState::Contested => "contested",
307                crate::bundle::ReviewState::NeedsRevision => "needs_revision",
308                crate::bundle::ReviewState::Rejected => "rejected",
309            })
310            .unwrap_or("none")),
311        conf = format_args!("{:.3}", finding.confidence.score),
312        basis = esc(&finding.confidence.basis),
313        retracted = finding.flags.retracted,
314        contested = finding.flags.contested,
315        n_sigs = signature_count,
316        n_ann = finding.annotations.len(),
317        src_type = esc(&finding.provenance.source_type),
318        doi = finding
319            .provenance
320            .doi
321            .as_deref()
322            .map(esc)
323            .unwrap_or_default(),
324        pmid = finding
325            .provenance
326            .pmid
327            .as_deref()
328            .map(esc)
329            .unwrap_or_default(),
330        year = finding
331            .provenance
332            .year
333            .map(|y| y.to_string())
334            .unwrap_or_default(),
335        journal = finding
336            .provenance
337            .journal
338            .as_deref()
339            .map(esc)
340            .unwrap_or_default(),
341        events_rows = events_rows,
342        n_events = related.len(),
343    );
344    page_shell(
345        &title,
346        &body,
347        &format!(
348            "<a href=\"../index.html\">home</a> &rsaquo; <a href=\"../findings.html\">findings</a> &rsaquo; <code>{}</code>",
349            esc(&finding.id)
350        ),
351    )
352}
353
354fn render_events(project: &Project) -> String {
355    let title = format!("{} · events", project.project.name);
356    let mut rows = String::new();
357    for e in &project.events {
358        rows.push_str(&format!(
359            "<tr><td><code>{id}</code></td><td>{kind}</td><td>{actor}</td><td>{target_kind}/{target_id}</td><td>{ts}</td></tr>",
360            id = esc(&e.id),
361            kind = esc(&e.kind),
362            actor = esc(&e.actor.id),
363            target_kind = esc(&e.target.r#type),
364            target_id = esc(&e.target.id),
365            ts = esc(&e.timestamp),
366        ));
367    }
368    let body = format!(
369        "<h1>Events</h1>
370<p>{n} canonical events on this frontier, in append order.</p>
371<table class=\"events\">
372<thead><tr><th>id</th><th>kind</th><th>actor</th><th>target</th><th>timestamp</th></tr></thead>
373<tbody>{rows}</tbody>
374</table>",
375        n = project.events.len(),
376        rows = rows,
377    );
378    page_shell(
379        &title,
380        &body,
381        "<a href=\"index.html\">home</a> &rsaquo; events",
382    )
383}
384
385fn css() -> String {
386    String::from(
387        r#"/* vela doc minimal stylesheet */
388:root {
389  --fg: #1a1a1a;
390  --bg: #fafafa;
391  --muted: #666;
392  --border: #ddd;
393  --accent: #c9a227;
394}
395* { box-sizing: border-box; }
396body {
397  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
398  font-size: 15px;
399  line-height: 1.5;
400  color: var(--fg);
401  background: var(--bg);
402  margin: 0;
403  padding: 0;
404}
405header { padding: 1rem 2rem; border-bottom: 1px solid var(--border); }
406header nav { font-size: 13px; color: var(--muted); }
407header nav a { color: var(--muted); text-decoration: none; }
408header nav a:hover { color: var(--fg); text-decoration: underline; }
409main { max-width: 980px; margin: 0 auto; padding: 2rem; }
410footer { max-width: 980px; margin: 2rem auto; padding: 1rem 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
411h1 { font-size: 24px; margin-top: 0; }
412h2 { font-size: 18px; margin-top: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
413.id, .description { color: var(--muted); font-size: 13px; }
414.assertion { font-size: 16px; padding: 1rem; background: white; border-left: 3px solid var(--accent); margin: 1rem 0; }
415table { border-collapse: collapse; width: 100%; font-size: 13px; }
416th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); vertical-align: top; }
417th { background: white; color: var(--muted); font-weight: 600; font-size: 12px; }
418code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; }
419a { color: var(--accent); }
420table.counts th, table.metadata th, table.provenance th { width: 200px; }
421section { margin-top: 1.5rem; }
422"#,
423    )
424}