archunit/graph/rendering/
html_renderer.rsuse crate::graph::{GraphReportEdge, GraphReportSnapshot};
use super::escaping::html;
use super::{D2Renderer, DotRenderer, JsonRenderer, MermaidRenderer};
#[derive(Debug, Clone, Copy, Default)]
pub struct HtmlRenderer;
impl HtmlRenderer {
#[must_use]
pub fn render(snapshot: &GraphReportSnapshot) -> String {
let title = html(&snapshot.title);
let body = [
summary_section(snapshot),
node_section(snapshot),
dependency_section(snapshot),
source_section(snapshot),
]
.join("\n");
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<style>
:root {{ color-scheme: light; font-family: Inter, Arial, sans-serif; }}
body {{ margin: 0; color: #172033; background: #f5f7fb; }}
header {{ padding: 28px 36px; color: white; background: #13294b; }}
header h1 {{ margin: 0 0 6px; font-size: 28px; }}
header p {{ margin: 0; color: #d7e3f4; }}
main {{ max-width: 1180px; margin: 0 auto; padding: 28px 36px 48px; }}
h2 {{ margin-top: 32px; font-size: 20px; }}
.summary {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }}
.metric {{ padding: 16px; border: 1px solid #d8deea; border-radius: 8px; background: white; }}
.metric strong {{ display: block; font-size: 26px; color: #13294b; }}
table {{ width: 100%; border-collapse: collapse; background: white; }}
th, td {{ padding: 9px 11px; border: 1px solid #d8deea; text-align: left; }}
th {{ background: #eaf0f8; }}
code, pre {{ font-family: Consolas, "SFMono-Regular", monospace; }}
pre {{ overflow: auto; padding: 16px; color: #e7edf7; background: #101827; border-radius: 8px; }}
details {{ margin: 12px 0; }}
summary {{ cursor: pointer; font-weight: 700; }}
.empty {{ padding: 16px; border: 1px solid #d8deea; background: white; }}
@media (max-width: 760px) {{ .summary {{ grid-template-columns: repeat(2, 1fr); }} }}
</style>
</head>
<body>
<header><h1>{title}</h1><p>Generated by ArchUnitRust graph reporting</p></header>
<main>{body}</main>
</body>
</html>"#
)
}
}
fn summary_section(snapshot: &GraphReportSnapshot) -> String {
let summary = snapshot.summary;
format!(
r#"<section class="summary" aria-label="Graph summary">
{}
{}
{}
{}
</section>"#,
metric(summary.node_count, "Nodes"),
metric(summary.edge_count, "Aggregated edges"),
metric(summary.raw_edge_count, "Raw edges"),
metric(summary.external_edge_count, "External edges")
)
}
fn metric(value: usize, label: &str) -> String {
format!(
"<div class=\"metric\"><strong>{value}</strong>{}</div>",
html(label)
)
}
fn node_section(snapshot: &GraphReportSnapshot) -> String {
let items = snapshot
.nodes
.iter()
.map(|node| format!("<li><code>{}</code></li>", html(&node.label)))
.collect::<String>();
format!("<section><h2>Nodes</h2><ul>{items}</ul></section>")
}
fn dependency_section(snapshot: &GraphReportSnapshot) -> String {
if snapshot.edges.is_empty() {
return "<section><h2>Dependencies</h2><div class=\"empty\">No dependency edges matched this graph query.</div></section>".to_owned();
}
let rows = snapshot
.edges
.iter()
.map(dependency_row)
.collect::<String>();
format!(
"<section><h2>Dependencies</h2><table><thead><tr><th>Source</th><th>Target</th><th>Count</th><th>External</th><th>Import kinds</th></tr></thead><tbody>{rows}</tbody></table></section>"
)
}
fn dependency_row(edge: &GraphReportEdge) -> String {
let kinds = edge
.import_kinds
.iter()
.map(|kind| kind.as_str())
.collect::<Vec<_>>()
.join(", ");
let values = [
edge.source.clone(),
edge.target.clone(),
edge.count.to_string(),
if edge.external { "yes" } else { "no" }.to_owned(),
kinds,
];
format!(
"<tr>{}</tr>",
values
.iter()
.map(|value| format!("<td>{}</td>", html(value)))
.collect::<String>()
)
}
fn source_section(snapshot: &GraphReportSnapshot) -> String {
let sources = [
("Mermaid", MermaidRenderer::render(snapshot)),
("DOT", DotRenderer::render(snapshot)),
("D2", D2Renderer::render(snapshot)),
("JSON snapshot", JsonRenderer::render(snapshot)),
];
let details = sources
.iter()
.map(|(name, source)| {
format!(
"<details><summary>{name}</summary><pre>{}</pre></details>",
html(source)
)
})
.collect::<String>();
format!("<section><h2>Portable sources</h2>{details}</section>")
}