From a1aec74a3493036c96dbbd63fcc6990d44056c11 Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Fri, 27 Mar 2026 15:39:34 +0100 Subject: [PATCH 1/2] Add `tracey export`: Static HTML spec export Probably fixes #143 Uses the daemon and most of the dashboard styles. There's a `--sources` flag, then the sources will also be included. I don't really care for it since I only want the spec exported as docs. I had Claude fill in the all the HTML noise. Not sure you like this style (I'm not too happy with it myself), but it does get the job done. It also invented a symbols list for the source view -- I'm gonna let it have that. --- crates/tracey/src/bridge/export.rs | 1065 ++++++++++++++++++++++++++ crates/tracey/src/bridge/http/mod.rs | 7 +- crates/tracey/src/bridge/mod.rs | 1 + crates/tracey/src/main.rs | 26 + 4 files changed, 1097 insertions(+), 2 deletions(-) create mode 100644 crates/tracey/src/bridge/export.rs diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs new file mode 100644 index 00000000..6b28bc6e --- /dev/null +++ b/crates/tracey/src/bridge/export.rs @@ -0,0 +1,1065 @@ +//! Static site export for tracey spec coverage data. +//! +//! `tracey export ` produces a fully self-contained directory of HTML +//! files that can be served by any static file host. No daemon or JavaScript +//! framework is required to view the exported pages. + +use std::path::{Path, PathBuf}; + +use eyre::{Result, WrapErr, eyre}; +use tracey_api::{ + ApiCodeUnit, ApiConfig, ApiFileData, ApiReverseData, ApiRule, ApiSpecData, ApiSpecForward, + OutlineEntry, +}; + +pub async fn run( + root: Option, + _config_path: PathBuf, + output: PathBuf, + include_sources: bool, +) -> Result<()> { + let project_root = match root { + Some(r) => r, + None => crate::find_project_root().wrap_err("finding project root")?, + }; + + let client = crate::daemon::new_client(project_root); + let config = client + .config() + .await + .map_err(|e| eyre!("config RPC failed: {:?}", e))?; + + std::fs::create_dir_all(&output) + .wrap_err_with(|| format!("creating output directory {}", output.display()))?; + write_assets(&output).wrap_err("writing static assets")?; + + let first = config.specs.first().and_then(|s| { + s.implementations + .first() + .map(|i| (s.name.clone(), i.clone())) + }); + write_root_index(&output, first.as_ref()).wrap_err("writing root index.html")?; + + for spec_info in &config.specs { + for impl_name in &spec_info.implementations { + let spec_name = &spec_info.name; + eprintln!("Exporting {spec_name} × {impl_name}…"); + + let forward = client + .forward(spec_name.clone(), impl_name.clone()) + .await + .map_err(|e| eyre!("forward RPC failed for {spec_name}/{impl_name}: {:?}", e))? + .ok_or_else(|| eyre!("no forward data for {spec_name}/{impl_name}"))?; + + let reverse = client + .reverse(spec_name.clone(), impl_name.clone()) + .await + .map_err(|e| eyre!("reverse RPC failed for {spec_name}/{impl_name}: {:?}", e))? + .ok_or_else(|| eyre!("no reverse data for {spec_name}/{impl_name}"))?; + + let spec_content = client + .spec_content(spec_name.clone(), impl_name.clone()) + .await + .map_err(|e| { + eyre!( + "spec_content RPC failed for {spec_name}/{impl_name}: {:?}", + e + ) + })? + .ok_or_else(|| eyre!("no spec content for {spec_name}/{impl_name}"))?; + + let pair_dir = output.join(spec_name).join(impl_name); + std::fs::create_dir_all(&pair_dir) + .wrap_err_with(|| format!("creating directory {}", pair_dir.display()))?; + + std::fs::write( + pair_dir.join("spec.html"), + render_spec_page( + spec_name, + impl_name, + &spec_content, + &config, + include_sources, + ) + .wrap_err_with(|| format!("rendering spec page for {spec_name}/{impl_name}"))?, + ) + .wrap_err_with(|| format!("writing {spec_name}/{impl_name}/spec.html"))?; + + std::fs::write( + pair_dir.join("coverage.html"), + render_coverage_page(spec_name, impl_name, &forward, &config, include_sources) + .wrap_err_with(|| { + format!("rendering coverage page for {spec_name}/{impl_name}") + })?, + ) + .wrap_err_with(|| format!("writing {spec_name}/{impl_name}/coverage.html"))?; + + if include_sources { + std::fs::write( + pair_dir.join("sources.html"), + render_sources_index(spec_name, impl_name, &reverse, &config).wrap_err_with( + || format!("rendering sources index for {spec_name}/{impl_name}"), + )?, + ) + .wrap_err_with(|| format!("writing {spec_name}/{impl_name}/sources.html"))?; + + let sources_dir = pair_dir.join("sources"); + for file_entry in &reverse.files { + let path = &file_entry.path; + let req = tracey_proto::FileRequest { + spec: spec_name.clone(), + impl_name: impl_name.clone(), + path: path.clone(), + }; + if let Some(file_data) = client + .file(req) + .await + .map_err(|e| eyre!("file RPC failed for {path}: {:?}", e))? + { + let file_html = render_file_page(spec_name, impl_name, &file_data, &config) + .wrap_err_with(|| format!("rendering file page for {path}"))?; + let out_path = sources_dir.join(format!("{path}.html")); + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent).wrap_err_with(|| { + format!("creating directory {}", parent.display()) + })?; + } + std::fs::write(&out_path, file_html) + .wrap_err_with(|| format!("writing {}", out_path.display()))?; + } + } + } + } + } + + eprintln!("\nDone! Static site written to: {}", output.display()); + eprintln!( + "Serve with: python3 -m http.server -d {}", + output.display() + ); + Ok(()) +} + +// ============================================================================ +// Assets +// ============================================================================ + +fn write_assets(output: &Path) -> Result<()> { + let assets_dir = output.join("assets"); + std::fs::create_dir_all(&assets_dir) + .wrap_err_with(|| format!("creating assets directory {}", assets_dir.display()))?; + + let full_css = format!("{}\n{}", crate::bridge::http::INDEX_CSS, STATIC_EXTRA_CSS); + std::fs::write(assets_dir.join("style.css"), full_css).wrap_err("writing assets/style.css")?; + std::fs::write(assets_dir.join("enhance.js"), ENHANCE_JS) + .wrap_err("writing assets/enhance.js")?; + Ok(()) +} + +fn write_root_index(output: &Path, first: Option<&(String, String)>) -> Result<()> { + let html = if let Some((spec, impl_name)) = first { + format!( + r#" + + + + + Tracey + +

Redirecting to spec

+"# + ) + } else { + "\n\nTracey\n

No specs configured.

\n\n".to_string() + }; + std::fs::write(output.join("index.html"), html).wrap_err("writing index.html")?; + Ok(()) +} + +// ============================================================================ +// Page shell — uses dashboard CSS classes directly +// ============================================================================ + +#[allow(clippy::too_many_arguments)] +fn page_shell( + title: &str, + spec_name: &str, + impl_name: &str, + active_tab: &str, // "spec" | "coverage" | "sources" + config: &ApiConfig, + include_sources: bool, + sidebar_html: &str, + content_html: &str, + head_extras: &str, +) -> String { + // Spec/impl selector tabs in the header-pickers area + let spec_links = config + .specs + .iter() + .flat_map(|s| { + s.implementations.iter().map(move |i| { + let active = if s.name == spec_name && i == impl_name { + " active" + } else { + "" + }; + format!( + r#"{} / {}"#, + s.name, + i, + html_escape(&s.name), + html_escape(i), + ) + }) + }) + .collect::>() + .join("\n"); + + // Nav tabs + let tab = |label: &str, icon: &str, href: &str, key: &str| { + let active = if active_tab == key { " active" } else { "" }; + format!( + r#"{label}"# + ) + }; + let sources_tab = if include_sources { + tab( + "Sources", + "code-2", + &format!("/{spec_name}/{impl_name}/sources.html"), + "sources", + ) + } else { + String::new() + }; + let nav_tabs = format!( + "{}\n{}\n{sources_tab}", + tab( + "Specification", + "file-text", + &format!("/{spec_name}/{impl_name}/spec.html"), + "spec" + ), + tab( + "Coverage", + "bar-chart-2", + &format!("/{spec_name}/{impl_name}/coverage.html"), + "coverage" + ), + ); + + // Sidebar (omit the