From 038e84d8bb951eceeac2a2b82d1fdadaf4ee4d30 Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Sat, 28 Mar 2026 10:41:21 +0100 Subject: [PATCH 01/13] export: Add spec section for static HTML export Documents the intent and requirements for `tracey export`: output structure, self-contained pages, landing page, mobile sidebar, and source file export. --- docs/content/spec/tracey.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/content/spec/tracey.md b/docs/content/spec/tracey.md index bda8f64b..ca5bd2e3 100644 --- a/docs/content/spec/tracey.md +++ b/docs/content/spec/tracey.md @@ -781,6 +781,42 @@ The `tracey web` command MUST start the HTTP dashboard server. r[cli.mcp] The `tracey mcp` command MUST start an MCP (Model Context Protocol) server over stdio. +r[cli.export] +The `tracey export ` command MUST generate a fully self-contained static HTML site in the given output directory. + +### Static HTML Export + +The export command produces a directory of HTML files that can be served by any static file host (GitHub Pages, S3, Netlify, etc.) without requiring the daemon at runtime. + +r[export.output-structure] +The export MUST produce an output directory containing: +- An `index.html` landing page at the root +- An `assets/` directory with CSS and JavaScript +- A subdirectory `{spec}/{impl}/` for each spec/implementation pair + +r[export.self-contained] +The exported site MUST NOT require the tracey daemon or any server-side processing to view. All content MUST be pre-rendered as static HTML. + +r[export.spec-page] +For each spec/implementation pair, the export MUST generate a `spec.html` page rendering the full specification with an outline sidebar. + +r[export.coverage-page] +For each spec/implementation pair, the export MUST generate a `coverage.html` page showing implementation and test coverage statistics and a table of all rules with their references. + +r[export.sources] +When the `--sources` flag is passed, the export MUST additionally generate: +- A `sources.html` index page listing all source files with coverage bars +- Individual `sources/{file}.html` pages with syntax-highlighted source code + +r[export.navigation] +Each exported page MUST include a header with spec/implementation selector tabs and navigation tabs (Specification, Coverage, and optionally Sources). + +r[export.landing-page] +The root `index.html` MUST serve as a landing page that lists all specs in the export as a navigable grid, allowing users to browse available specifications. + +r[export.mobile-sidebar] +On viewports narrower than a desktop breakpoint, the outline sidebar MUST be hidden by default and togglable via a button, so the export is usable on mobile devices. + ## Server Architecture Both `tracey serve` (HTTP) and `tracey mcp` (MCP) share a common headless server core. From e8522ea9f5ae0d14c96e2730ca208a76465cb894 Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Sat, 28 Mar 2026 10:42:05 +0100 Subject: [PATCH 02/13] export: Fix mobile sidebar On viewports <= 768px the outline sidebar slides off-screen and a toggle button appears in the header. Tapping the button opens the sidebar as an overlay with a backdrop; tapping the backdrop closes it. --- crates/tracey/src/bridge/export.rs | 71 +++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs index 6b28bc6e..0e071664 100644 --- a/crates/tracey/src/bridge/export.rs +++ b/crates/tracey/src/bridge/export.rs @@ -253,10 +253,17 @@ fn page_shell( String::new() } else { format!( - r#""# + r#""# ) }; + // Mobile sidebar toggle button (only rendered when sidebar has content) + let sidebar_toggle = if sidebar_html.is_empty() { + String::new() + } else { + r#""#.to_string() + }; + format!( r#" @@ -287,6 +294,7 @@ fn page_shell( + {sidebar_toggle} @@ -997,6 +1005,23 @@ const ENHANCE_JS: &str = r##"(function () { navigator.clipboard.writeText(btn.dataset.reqId).catch(function () {}); } }); + + // Mobile sidebar toggle + var toggle = document.getElementById("sidebar-toggle"); + var sidebar = document.getElementById("sidebar"); + if (toggle && sidebar) { + var backdrop = document.createElement("div"); + backdrop.className = "sidebar-backdrop"; + document.body.appendChild(backdrop); + + function openSidebar() { sidebar.classList.add("open"); backdrop.classList.add("open"); } + function closeSidebar() { sidebar.classList.remove("open"); backdrop.classList.remove("open"); } + + toggle.addEventListener("click", function () { + sidebar.classList.contains("open") ? closeSidebar() : openSidebar(); + }); + backdrop.addEventListener("click", closeSidebar); + } })(); "##; @@ -1062,4 +1087,48 @@ a.spec-tab { text-decoration: none; display: inline-block; } .cov-bar-fill.high { background: var(--green); } .cov-bar-fill.med { background: var(--yellow); } .cov-bar-fill.low { background: var(--red); } + +/* Mobile sidebar toggle */ +.sidebar-toggle { + display: none; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg-muted); + cursor: pointer; + padding: var(--space-1) var(--space-2); + align-items: center; + justify-content: center; +} +.sidebar-toggle:hover { color: var(--fg); background: var(--hover); } +.sidebar-toggle svg { width: 18px; height: 18px; } + +@media (max-width: 768px) { + .sidebar-toggle { display: inline-flex; } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 100; + width: 300px; + max-width: 85vw; + transform: translateX(-100%); + transition: transform 0.2s ease; + background: var(--bg); + border-right: 1px solid var(--border); + overflow-y: auto; + } + .sidebar.open { transform: translateX(0); } + + .sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + z-index: 99; + background: rgba(0, 0, 0, 0.4); + } + .sidebar-backdrop.open { display: block; } +} "#; From c886165ddb75a319fdb03d6f0cb7865a7d261d34 Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Sat, 28 Mar 2026 10:43:17 +0100 Subject: [PATCH 03/13] export: Annotate code with spec requirement references Links implementation functions to their corresponding spec requirements (cli.export, export.*, export.mobile-sidebar). --- crates/tracey/src/bridge/export.rs | 9 ++++++++- crates/tracey/src/main.rs | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs index 0e071664..e3a4dbef 100644 --- a/crates/tracey/src/bridge/export.rs +++ b/crates/tracey/src/bridge/export.rs @@ -12,6 +12,8 @@ use tracey_api::{ OutlineEntry, }; +// r[impl export.output-structure] +// r[impl export.self-contained] pub async fn run( root: Option, _config_path: PathBuf, @@ -156,6 +158,7 @@ fn write_assets(output: &Path) -> Result<()> { Ok(()) } +// r[impl export.landing-page] fn write_root_index(output: &Path, first: Option<&(String, String)>) -> Result<()> { let html = if let Some((spec, impl_name)) = first { format!( @@ -180,6 +183,7 @@ fn write_root_index(output: &Path, first: Option<&(String, String)>) -> Result<( // Page shell — uses dashboard CSS classes directly // ============================================================================ +// r[impl export.navigation] #[allow(clippy::too_many_arguments)] fn page_shell( title: &str, @@ -318,6 +322,7 @@ fn page_shell( // Spec page // ============================================================================ +// r[impl export.spec-page] fn render_spec_page( spec_name: &str, impl_name: &str, @@ -448,6 +453,7 @@ fn coverage_arc_svg(count: usize, total: usize, color: &str, title: &str) -> Str // Coverage page // ============================================================================ +// r[impl export.coverage-page] fn render_coverage_page( spec_name: &str, impl_name: &str, @@ -599,6 +605,7 @@ fn stat_class(count: usize, total: usize) -> &'static str { // Sources index page // ============================================================================ +// r[impl export.sources] fn render_sources_index( spec_name: &str, impl_name: &str, @@ -1088,7 +1095,7 @@ a.spec-tab { text-decoration: none; display: inline-block; } .cov-bar-fill.med { background: var(--yellow); } .cov-bar-fill.low { background: var(--red); } -/* Mobile sidebar toggle */ +/* r[impl export.mobile-sidebar] Mobile sidebar toggle */ .sidebar-toggle { display: none; background: none; diff --git a/crates/tracey/src/main.rs b/crates/tracey/src/main.rs index 43375990..df0bf6c5 100644 --- a/crates/tracey/src/main.rs +++ b/crates/tracey/src/main.rs @@ -183,6 +183,7 @@ enum Command { }, /// Export a static, deployable site from the current spec coverage data. + // r[impl cli.export] Export { /// Output directory (will be created; existing contents overwritten) #[facet(args::positional)] From fa11471324e6acedcfe539684577ae85b26e59a2 Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Sat, 28 Mar 2026 10:49:15 +0100 Subject: [PATCH 04/13] export: Add landing page Replace the redirect-based root index.html with a proper landing page that renders the project's README.md (or a default) and displays a grid of all spec/implementation pairs with coverage stats. --- crates/tracey/src/bridge/export.rs | 213 ++++++++++++++++++++++++++--- docs/content/spec/tracey.md | 11 +- 2 files changed, 205 insertions(+), 19 deletions(-) diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs index e3a4dbef..7a4344b8 100644 --- a/crates/tracey/src/bridge/export.rs +++ b/crates/tracey/src/bridge/export.rs @@ -25,7 +25,7 @@ pub async fn run( None => crate::find_project_root().wrap_err("finding project root")?, }; - let client = crate::daemon::new_client(project_root); + let client = crate::daemon::new_client(project_root.clone()); let config = client .config() .await @@ -35,12 +35,8 @@ pub async fn run( .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")?; + // Collect coverage stats for the landing page while exporting each pair + let mut spec_cards: Vec = Vec::new(); for spec_info in &config.specs { for impl_name in &spec_info.implementations { @@ -70,6 +66,26 @@ pub async fn run( })? .ok_or_else(|| eyre!("no spec content for {spec_name}/{impl_name}"))?; + // Collect stats for landing page + let total = forward.rules.len(); + let implemented = forward + .rules + .iter() + .filter(|r| !r.impl_refs.is_empty()) + .count(); + let tested = forward + .rules + .iter() + .filter(|r| !r.verify_refs.is_empty()) + .count(); + spec_cards.push(SpecCard { + spec_name: spec_name.clone(), + impl_name: impl_name.clone(), + total, + implemented, + tested, + }); + 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()))?; @@ -134,6 +150,11 @@ pub async fn run( } } + // Generate landing page + write_landing_page(&output, &project_root, &spec_cards) + .await + .wrap_err("writing landing page")?; + eprintln!("\nDone! Static site written to: {}", output.display()); eprintln!( "Serve with: python3 -m http.server -d {}", @@ -142,6 +163,14 @@ pub async fn run( Ok(()) } +struct SpecCard { + spec_name: String, + impl_name: String, + total: usize, + implemented: usize, + tested: usize, +} + // ============================================================================ // Assets // ============================================================================ @@ -159,22 +188,114 @@ fn write_assets(output: &Path) -> Result<()> { } // r[impl export.landing-page] -fn write_root_index(output: &Path, first: Option<&(String, String)>) -> Result<()> { - let html = if let Some((spec, impl_name)) = first { - format!( - r#" - +// r[impl export.landing-page.default-content] +// r[impl export.landing-page.readme] +// r[impl export.landing-page.spec-grid] +async fn write_landing_page( + output: &Path, + project_root: &Path, + spec_cards: &[SpecCard], +) -> Result<()> { + // Try to read and render README.md from the project root + let readme_path = project_root.join("README.md"); + let intro_html = if readme_path.is_file() { + let readme_content = std::fs::read_to_string(&readme_path).wrap_err("reading README.md")?; + let opts = marq::RenderOptions::default(); + let doc = marq::render(&readme_content, &opts) + .await + .wrap_err("rendering README.md")?; + doc.html + } else { + r#"

Specifications

Browse the exported specifications below.

"#.to_string() + }; + + // Build spec card grid + let cards = spec_cards + .iter() + .map(|card| { + let impl_class = stat_class(card.implemented, card.total); + let test_class = stat_class(card.tested, card.total); + let impl_arc = coverage_arc_svg( + card.implemented, + card.total, + "var(--green)", + &format!("Impl: {}/{}", card.implemented, card.total), + ); + let test_arc = coverage_arc_svg( + card.tested, + card.total, + "var(--blue)", + &format!("Tests: {}/{}", card.tested, card.total), + ); + format!( + r#" +
+ {spec_escaped} + {impl_escaped} +
+
+ {impl_arc} {implemented}/{total} implemented + {test_arc} {tested}/{total} tested +
+
"#, + spec = card.spec_name, + impl_name = card.impl_name, + spec_escaped = html_escape(&card.spec_name), + impl_escaped = html_escape(&card.impl_name), + implemented = card.implemented, + tested = card.tested, + total = card.total, + impl_class = impl_class, + test_class = test_class, + ) + }) + .collect::>() + .join("\n"); + + let content = format!( + r#"
+
{intro_html}
+
+

Specifications

+
{cards}
+
+
"# + ); + + let html = format!( + r#" + - + + Tracey + + + + -

Redirecting to spec

+ +
+
+
+
+ +
+
+
+
+
+ {content} +
+
+
+
+
+ "# - ) - } 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(()) } @@ -1138,4 +1259,60 @@ a.spec-tab { text-decoration: none; display: inline-block; } } .sidebar-backdrop.open { display: block; } } + +/* Landing page */ +.landing-page { + padding: var(--space-6) var(--space-8); + max-width: 1000px; +} +.landing-readme { margin-bottom: var(--space-8); } +.landing-grid-section h2 { + font-size: var(--text-lg); + margin-bottom: var(--space-4); + color: var(--fg); +} +.landing-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-4); +} +.spec-card { + display: block; + text-decoration: none; + color: var(--fg); + border: 1px solid var(--border); + border-radius: 8px; + padding: var(--space-4); + transition: border-color 0.15s, box-shadow 0.15s; +} +.spec-card:hover { + border-color: var(--accent); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} +.spec-card-header { + display: flex; + align-items: baseline; + gap: var(--space-2); + margin-bottom: var(--space-3); +} +.spec-card-name { + font-size: var(--text-base); + font-weight: 600; +} +.spec-card-impl { + font-size: var(--text-sm); + color: var(--fg-muted); +} +.spec-card-stats { + display: flex; + flex-direction: column; + gap: var(--space-1); +} +.spec-card-stat { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--fg-muted); +} "#; diff --git a/docs/content/spec/tracey.md b/docs/content/spec/tracey.md index ca5bd2e3..0ba7f8ef 100644 --- a/docs/content/spec/tracey.md +++ b/docs/content/spec/tracey.md @@ -812,7 +812,16 @@ r[export.navigation] Each exported page MUST include a header with spec/implementation selector tabs and navigation tabs (Specification, Coverage, and optionally Sources). r[export.landing-page] -The root `index.html` MUST serve as a landing page that lists all specs in the export as a navigable grid, allowing users to browse available specifications. +The root `index.html` MUST serve as a landing page rather than a redirect. + +> r[export.landing-page.default-content] +> When no `README.md` is present in the project root, the landing page MUST render a sensible default describing the export and linking to the specs. + +> r[export.landing-page.readme] +> When a `README.md` file exists in the project root, its rendered markdown content MUST be included on the landing page above the spec grid. + +> r[export.landing-page.spec-grid] +> The landing page MUST display a grid of all spec/implementation pairs in the export, each linking to its spec page. Each card MUST show the spec name, implementation name, and coverage summary (implemented/tested counts). r[export.mobile-sidebar] On viewports narrower than a desktop breakpoint, the outline sidebar MUST be hidden by default and togglable via a button, so the export is usable on mobile devices. From c5c9f0b1710dbd7bf6eda2256c7270c2ef46d991 Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Sat, 28 Mar 2026 11:21:10 +0100 Subject: [PATCH 05/13] export: Restyle requirement blocks - .req-covered: remove opacity so fully-covered requirements are easily readable - .req-partial: dashed border + "partially covered" label - .req-uncovered: "not yet covered" label --- crates/tracey/src/bridge/export.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs index 7a4344b8..4b1bdcd1 100644 --- a/crates/tracey/src/bridge/export.rs +++ b/crates/tracey/src/bridge/export.rs @@ -1158,6 +1158,28 @@ const ENHANCE_JS: &str = r##"(function () { const STATIC_EXTRA_CSS: &str = r#" /* ── Static export extras ─────────────────────────────────────── */ +/* Requirement coverage — readable for static export readers */ +.req-covered { opacity: 1; } +.req-partial { + border-style: dashed; +} +.req-partial::after { + content: "partially covered"; + display: inline-block; + font-size: var(--text-xs); + color: var(--yellow); + margin-left: var(--space-2); + font-style: italic; +} +.req-uncovered::after { + content: "not yet covered"; + display: inline-block; + font-size: var(--text-xs); + color: var(--accent); + margin-left: var(--space-2); + font-style: italic; +} + /* spec-tab links (header pickers) */ a.spec-tab { text-decoration: none; display: inline-block; } From dc1cab53892308a48b2a965dac055c1592d55bd9 Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Sat, 28 Mar 2026 11:27:17 +0100 Subject: [PATCH 06/13] export: Add home link to header --- crates/tracey/src/bridge/export.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs index 4b1bdcd1..177b028c 100644 --- a/crates/tracey/src/bridge/export.rs +++ b/crates/tracey/src/bridge/export.rs @@ -413,6 +413,7 @@ fn page_shell(
+
{spec_links}
@@ -1180,6 +1181,18 @@ const STATIC_EXTRA_CSS: &str = r#" font-style: italic; } +/* Home link in header */ +.home-link { + display: inline-flex; + align-items: center; + margin: var(--space-2); + color: var(--fg-muted); + padding: var(--space-1); + border-radius: 4px; +} +.home-link:hover { color: var(--fg); background: var(--hover); } +.home-link svg { width: 18px; height: 18px; } + /* spec-tab links (header pickers) */ a.spec-tab { text-decoration: none; display: inline-block; } From 082c2c037b775b58d7513e05d4246344418682fd Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Sat, 28 Mar 2026 17:40:30 +0100 Subject: [PATCH 07/13] fix mobile view --- crates/tracey/src/bridge/export.rs | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs index 177b028c..654ccb9d 100644 --- a/crates/tracey/src/bridge/export.rs +++ b/crates/tracey/src/bridge/export.rs @@ -1293,6 +1293,86 @@ a.spec-tab { text-decoration: none; display: inline-block; } background: rgba(0, 0, 0, 0.4); } .sidebar-backdrop.open { display: block; } + + /* Compact header: wrap nav below pickers */ + .header-inner { + flex-wrap: wrap; + } + .header-pickers { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + flex-shrink: 1; + min-width: 0; + } + .nav { + order: 10; + width: 100%; + border-top: 1px solid var(--border); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .nav-tab { + padding: var(--space-2) var(--space-3); + font-size: var(--text-xs); + white-space: nowrap; + } + .logo { + padding: var(--space-2) var(--space-3); + font-size: var(--text-base); + } + + /* Reduce page padding */ + .spec-page { + padding: var(--space-4) var(--space-3); + } + .padded-page { + padding: var(--space-3) var(--space-2); + } + .landing-page { + padding: var(--space-4) var(--space-3); + } + + /* Requirement containers: compact badges */ + .req-container { + padding: var(--space-4) var(--space-3) var(--space-3) var(--space-3); + margin: var(--space-5) 0; + } + .req-badges-left, + .req-badges-right { + position: relative; + top: auto; + inset-inline-start: auto; + inset-inline-end: auto; + flex-wrap: wrap; + gap: var(--space-0-5); + margin-bottom: var(--space-1); + } + .req-badges-right { + margin-bottom: var(--space-2); + } + .req-badge { + font-size: var(--text-2xs); + padding: var(--space-0-5) var(--space-1-5); + white-space: nowrap; + } + + /* Coverage table: horizontal scroll */ + .padded-page table { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .rule-refs { + white-space: nowrap; + } + + /* Source file header */ + .file-view-header { + font-size: var(--text-xs); + padding: var(--space-2) var(--space-3); + overflow-x: auto; + word-break: break-all; + } } /* Landing page */ From be4882e3d80dd86b95e459101ec80845014838d3 Mon Sep 17 00:00:00 2001 From: Pascal Hertleif Date: Tue, 7 Apr 2026 16:05:29 +0200 Subject: [PATCH 08/13] nicer export --- Cargo.lock | 46 + Cargo.toml | 3 + crates/tracey/Cargo.toml | 3 + crates/tracey/src/bridge/export.rs | 1433 ----------------- crates/tracey/src/bridge/export/components.rs | 348 ++++ crates/tracey/src/bridge/export/enhance.js | 159 ++ crates/tracey/src/bridge/export/extra.css | 404 +++++ crates/tracey/src/bridge/export/mod.rs | 196 +++ crates/tracey/src/bridge/export/pages.rs | 495 ++++++ crates/tracey/src/bridge/export/tests.rs | 56 + docs/content/spec/tracey.md | 115 +- 11 files changed, 1797 insertions(+), 1461 deletions(-) delete mode 100644 crates/tracey/src/bridge/export.rs create mode 100644 crates/tracey/src/bridge/export/components.rs create mode 100644 crates/tracey/src/bridge/export/enhance.js create mode 100644 crates/tracey/src/bridge/export/extra.css create mode 100644 crates/tracey/src/bridge/export/mod.rs create mode 100644 crates/tracey/src/bridge/export/pages.rs create mode 100644 crates/tracey/src/bridge/export/tests.rs 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 654ccb9d..00000000 --- a/crates/tracey/src/bridge/export.rs +++ /dev/null @@ -1,1433 +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, -}; - -// r[impl export.output-structure] -// r[impl export.self-contained] -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.clone()); - 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")?; - - // Collect coverage stats for the landing page while exporting each pair - let mut spec_cards: Vec = Vec::new(); - - 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}"))?; - - // Collect stats for landing page - let total = forward.rules.len(); - let implemented = forward - .rules - .iter() - .filter(|r| !r.impl_refs.is_empty()) - .count(); - let tested = forward - .rules - .iter() - .filter(|r| !r.verify_refs.is_empty()) - .count(); - spec_cards.push(SpecCard { - spec_name: spec_name.clone(), - impl_name: impl_name.clone(), - total, - implemented, - tested, - }); - - 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()))?; - } - } - } - } - } - - // Generate landing page - write_landing_page(&output, &project_root, &spec_cards) - .await - .wrap_err("writing landing page")?; - - eprintln!("\nDone! Static site written to: {}", output.display()); - eprintln!( - "Serve with: python3 -m http.server -d {}", - output.display() - ); - Ok(()) -} - -struct SpecCard { - spec_name: String, - impl_name: String, - total: usize, - implemented: usize, - tested: usize, -} - -// ============================================================================ -// 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(()) -} - -// r[impl export.landing-page] -// r[impl export.landing-page.default-content] -// r[impl export.landing-page.readme] -// r[impl export.landing-page.spec-grid] -async fn write_landing_page( - output: &Path, - project_root: &Path, - spec_cards: &[SpecCard], -) -> Result<()> { - // Try to read and render README.md from the project root - let readme_path = project_root.join("README.md"); - let intro_html = if readme_path.is_file() { - let readme_content = std::fs::read_to_string(&readme_path).wrap_err("reading README.md")?; - let opts = marq::RenderOptions::default(); - let doc = marq::render(&readme_content, &opts) - .await - .wrap_err("rendering README.md")?; - doc.html - } else { - r#"

Specifications

Browse the exported specifications below.

"#.to_string() - }; - - // Build spec card grid - let cards = spec_cards - .iter() - .map(|card| { - let impl_class = stat_class(card.implemented, card.total); - let test_class = stat_class(card.tested, card.total); - let impl_arc = coverage_arc_svg( - card.implemented, - card.total, - "var(--green)", - &format!("Impl: {}/{}", card.implemented, card.total), - ); - let test_arc = coverage_arc_svg( - card.tested, - card.total, - "var(--blue)", - &format!("Tests: {}/{}", card.tested, card.total), - ); - format!( - r#" -
- {spec_escaped} - {impl_escaped} -
-
- {impl_arc} {implemented}/{total} implemented - {test_arc} {tested}/{total} tested -
-
"#, - spec = card.spec_name, - impl_name = card.impl_name, - spec_escaped = html_escape(&card.spec_name), - impl_escaped = html_escape(&card.impl_name), - implemented = card.implemented, - tested = card.tested, - total = card.total, - impl_class = impl_class, - test_class = test_class, - ) - }) - .collect::>() - .join("\n"); - - let content = format!( - r#"
-
{intro_html}
-
-

Specifications

-
{cards}
-
-
"# - ); - - let html = format!( - r#" - - - - - - Tracey - - - - - - -
-
-
-
- -
-
-
-
-
- {content} -
-
-
-
-
- -"# - ); - - std::fs::write(output.join("index.html"), html).wrap_err("writing index.html")?; - Ok(()) -} - -// ============================================================================ -// Page shell — uses dashboard CSS classes directly -// ============================================================================ - -// r[impl export.navigation] -#[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