diff --git a/Cargo.lock b/Cargo.lock index 338e08d1..ce6d2cbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2631,6 +2631,28 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maud" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "measure_time" version = "0.8.3" @@ -3121,6 +3143,29 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -4392,6 +4437,7 @@ dependencies = [ "ignore", "indoc", "marq", + "maud", "notify", "open", "owo-colors", diff --git a/Cargo.toml b/Cargo.toml index 6ab2f6d3..51944075 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,9 @@ notify = "8" # Full-text search tantivy = "0.22" +# HTML templating (compile-time, component-style) +maud = "0.26" + # Markdown processing (with syntax highlighting and diagram handlers) marq = { version = "2.2.0", features = ["all-handlers", "lang-vixen"] } pulldown-cmark = "0.13" diff --git a/crates/tracey/Cargo.toml b/crates/tracey/Cargo.toml index ae588f6e..7e618ba2 100644 --- a/crates/tracey/Cargo.toml +++ b/crates/tracey/Cargo.toml @@ -55,6 +55,9 @@ indoc = { workspace = true } ignore = { workspace = true } globset = { workspace = true } +# HTML templating (compile-time) +maud = { workspace = true } + # Markdown rendering with syntax highlighting and diagrams marq = { workspace = true } diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs deleted file mode 100644 index 6b28bc6e..00000000 --- a/crates/tracey/src/bridge/export.rs +++ /dev/null @@ -1,1065 +0,0 @@ -//! 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