Skip to main content

xtask/
doc_site.rs

1//! Landing page and OpenAPI (Redoc) assets for `cargo xtask doc`.
2//!
3//! `cargo doc` leaves `target/doc/` as a bare pile of per-crate directories,
4//! which is exactly what GitHub Pages would serve as the site root. This
5//! module gives the doc root a real entry point:
6//!
7//! - `index.html` — a small landing page (inline CSS, no external assets)
8//!   listing every workspace crate with its `description` from
9//!   `cargo metadata`, each linking into that crate's rustdoc.
10//! - `openapi.json` — a copy of the checked-in REST contract
11//!   (`docs/generated/openapi.json`) so the doc site is self-contained.
12//! - `openapi.html` — renders that contract with the Redoc standalone bundle
13//!   (the one external asset, loaded from the Redoc CDN at view time).
14//!
15//! `.github/workflows/docs.yml` builds docs through `cargo xtask doc --strict`,
16//! so what Pages deploys is exactly what this module writes — the workflow no
17//! longer carries its own inline HTML step.
18
19use anyhow::{bail, Context, Result};
20use std::path::Path;
21use std::process::{Command, Stdio};
22
23/// Relative path (from the repo root) of the generated OpenAPI contract that
24/// gets copied beside the landing page. Kept current by `cargo xtask
25/// check-openapi`, so the doc site always renders the same contract CI gates.
26const OPENAPI_SOURCE: &str = "docs/generated/openapi.json";
27
28/// One workspace crate as rendered on the landing page.
29struct CrateEntry {
30    /// Package name as written in `Cargo.toml` (hyphenated).
31    name: String,
32    /// rustdoc output directory under `target/doc/` — the documented target's
33    /// name with hyphens folded to underscores, which is how rustdoc names it.
34    doc_dir: String,
35    /// `description` from the crate manifest; empty when the crate has none.
36    description: String,
37}
38
39/// Write the landing page and OpenAPI assets into `doc_root`
40/// (normally `target/doc/`).
41///
42/// Called by `cargo xtask doc` after a successful `cargo doc` run — including
43/// `-p` partial builds, where crate links into undocumented crates will 404
44/// locally until a full workspace build fills them in. Emitting
45/// unconditionally keeps the output deterministic: the page is derived from
46/// `cargo metadata`, not from whichever subset happened to be built.
47pub(crate) fn emit(doc_root: &Path) -> Result<()> {
48    let entries = workspace_crates()?;
49    std::fs::create_dir_all(doc_root)
50        .with_context(|| format!("failed to create {}", doc_root.display()))?;
51
52    let index_path = doc_root.join("index.html");
53    std::fs::write(&index_path, landing_html(&entries))
54        .with_context(|| format!("failed to write {}", index_path.display()))?;
55    println!("==> Wrote landing page {}", index_path.display());
56
57    let openapi_source = Path::new(OPENAPI_SOURCE);
58    if openapi_source.is_file() {
59        let spec_path = doc_root.join("openapi.json");
60        std::fs::copy(openapi_source, &spec_path)
61            .with_context(|| format!("failed to copy {OPENAPI_SOURCE} to doc root"))?;
62        let redoc_path = doc_root.join("openapi.html");
63        std::fs::write(&redoc_path, REDOC_HTML)
64            .with_context(|| format!("failed to write {}", redoc_path.display()))?;
65        println!("==> Wrote OpenAPI page {}", redoc_path.display());
66    } else {
67        // Tolerated rather than fatal so `cargo xtask doc` still works from a
68        // stripped checkout; in this repo the contract is always tracked.
69        println!("==> Skipped OpenAPI page: {OPENAPI_SOURCE} not found");
70    }
71    Ok(())
72}
73
74/// Enumerate workspace crates via `cargo metadata --no-deps`.
75///
76/// Parsed with `serde_json` rather than the `cargo_metadata` crate to keep
77/// xtask dependency-light (see xtask/Cargo.toml's preamble). Only the fields
78/// the landing page needs are read: package name, description, and the
79/// documented target's name for the rustdoc directory.
80fn workspace_crates() -> Result<Vec<CrateEntry>> {
81    let output = Command::new("cargo")
82        .args(["metadata", "--format-version", "1", "--no-deps", "--locked"])
83        .stdin(Stdio::null())
84        .output()
85        .context("Failed to spawn `cargo metadata`")?;
86    if !output.status.success() {
87        bail!("`cargo metadata` exited with status {}", output.status);
88    }
89    let metadata: serde_json::Value =
90        serde_json::from_slice(&output.stdout).context("`cargo metadata` emitted invalid JSON")?;
91    let packages = metadata
92        .get("packages")
93        .and_then(|value| value.as_array())
94        .context("`cargo metadata` output has no `packages` array")?;
95
96    let mut entries = Vec::new();
97    for package in packages {
98        let Some(name) = package.get("name").and_then(|value| value.as_str()) else {
99            continue;
100        };
101        let Some(doc_target) = documented_target_name(package) else {
102            // Nothing rustdoc would document (no lib/bin target) — skip.
103            continue;
104        };
105        let description = package
106            .get("description")
107            .and_then(|value| value.as_str())
108            .unwrap_or("")
109            .to_owned();
110        entries.push(CrateEntry {
111            name: name.to_owned(),
112            doc_dir: doc_target.replace('-', "_"),
113            description,
114        });
115    }
116    entries.sort_by(|a, b| a.name.cmp(&b.name));
117    Ok(entries)
118}
119
120/// Pick the target `cargo doc` documents for a package: the lib-like target
121/// when present (lib/rlib/dylib/cdylib/staticlib/proc-macro), otherwise the
122/// first bin. When a package has both a lib and a same-named bin (apps/soma),
123/// rustdoc documents the lib, so lib-first matches the output on disk.
124fn documented_target_name(package: &serde_json::Value) -> Option<String> {
125    let targets = package.get("targets")?.as_array()?;
126    let kind_of = |target: &serde_json::Value| -> Vec<String> {
127        target
128            .get("kind")
129            .and_then(|value| value.as_array())
130            .map(|kinds| {
131                kinds
132                    .iter()
133                    .filter_map(|kind| kind.as_str().map(str::to_owned))
134                    .collect()
135            })
136            .unwrap_or_default()
137    };
138    const LIB_KINDS: &[&str] = &["lib", "rlib", "dylib", "cdylib", "staticlib", "proc-macro"];
139    for target in targets {
140        let kinds = kind_of(target);
141        if kinds.iter().any(|kind| LIB_KINDS.contains(&kind.as_str())) {
142            return target
143                .get("name")
144                .and_then(|value| value.as_str())
145                .map(str::to_owned);
146        }
147    }
148    for target in targets {
149        if kind_of(target).iter().any(|kind| kind == "bin") {
150            return target
151                .get("name")
152                .and_then(|value| value.as_str())
153                .map(str::to_owned);
154        }
155    }
156    None
157}
158
159/// Render the landing page. Inline CSS only; the sole external reference on
160/// the whole doc site is the Redoc bundle inside `openapi.html`.
161fn landing_html(entries: &[CrateEntry]) -> String {
162    let mut rows = String::new();
163    for entry in entries {
164        let description = if entry.description.is_empty() {
165            String::new()
166        } else {
167            format!(
168                " <span class=\"desc\">— {}</span>",
169                escape_html(&entry.description)
170            )
171        };
172        rows.push_str(&format!(
173            "      <li><a href=\"{dir}/index.html\"><code>{name}</code></a>{description}</li>\n",
174            dir = escape_html(&entry.doc_dir),
175            name = escape_html(&entry.name),
176        ));
177    }
178    format!(
179        r#"<!DOCTYPE html>
180<html lang="en">
181  <head>
182    <meta charset="utf-8">
183    <meta name="viewport" content="width=device-width, initial-scale=1">
184    <title>Soma API documentation</title>
185    <style>
186      :root {{ color-scheme: light dark; }}
187      body {{
188        font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
189        max-width: 56rem; margin: 2rem auto; padding: 0 1.25rem;
190        line-height: 1.55;
191      }}
192      h1 {{ margin-bottom: 0.25rem; }}
193      .subtitle {{ color: color-mix(in srgb, currentColor 65%, transparent); }}
194      a {{ color: #0969da; text-decoration: none; }}
195      a:hover {{ text-decoration: underline; }}
196      @media (prefers-color-scheme: dark) {{ a {{ color: #58a6ff; }} }}
197      .card {{
198        display: block; border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
199        border-radius: 8px; padding: 0.9rem 1.1rem; margin: 1.25rem 0;
200      }}
201      .card strong {{ display: block; }}
202      ul.crates {{ list-style: none; padding: 0; }}
203      ul.crates li {{ padding: 0.3rem 0; border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); }}
204      .desc {{ color: color-mix(in srgb, currentColor 65%, transparent); }}
205      footer {{ margin-top: 2rem; font-size: 0.85rem; color: color-mix(in srgb, currentColor 55%, transparent); }}
206    </style>
207  </head>
208  <body>
209    <h1>Soma API documentation</h1>
210    <p class="subtitle">Rust API reference for every crate in the soma workspace, built by <code>cargo xtask doc</code>.</p>
211    <a class="card" href="openapi.html">
212      <strong>REST API reference (OpenAPI)</strong>
213      <span class="desc">The <code>/v1/*</code> HTTP contract from <code>docs/generated/openapi.json</code>, rendered with Redoc.</span>
214    </a>
215    <h2>Workspace crates</h2>
216    <ul class="crates">
217{rows}    </ul>
218    <footer>Generated by <code>cargo xtask doc</code>; deployed from <code>.github/workflows/docs.yml</code>.</footer>
219  </body>
220</html>
221"#
222    )
223}
224
225/// Minimal HTML escaping for text interpolated into the landing page.
226/// Descriptions come from our own Cargo manifests, but escaping keeps the
227/// generator correct no matter what a future manifest says.
228fn escape_html(text: &str) -> String {
229    text.replace('&', "&amp;")
230        .replace('<', "&lt;")
231        .replace('>', "&gt;")
232        .replace('"', "&quot;")
233}
234
235/// Static Redoc host page. `spec-url` is relative so the page works both on
236/// GitHub Pages and from a local `target/doc/` checkout; the standalone
237/// bundle comes from the Redoc CDN (the doc site's only external asset).
238const REDOC_HTML: &str = r#"<!DOCTYPE html>
239<html lang="en">
240  <head>
241    <meta charset="utf-8">
242    <meta name="viewport" content="width=device-width, initial-scale=1">
243    <title>Soma REST API — OpenAPI reference</title>
244    <style>
245      body { margin: 0; padding: 0; }
246      .back { font-family: system-ui, sans-serif; font-size: 0.9rem; padding: 0.5rem 1rem; }
247    </style>
248  </head>
249  <body>
250    <div class="back"><a href="index.html">&larr; All Soma API docs</a></div>
251    <redoc spec-url="openapi.json"></redoc>
252    <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
253  </body>
254</html>
255"#;