use std::fmt::Write as _;
use std::path::Path;
use crate::check::{self, CheckReport};
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn anchor(scope: &str) -> String {
if scope.is_empty() {
"root".to_string()
} else {
scope.replace('/', "-")
}
}
fn inline(text: &str, scope: &str) -> String {
let mut out = String::new();
let mut rest = text;
while let Some(start) = rest.find('[') {
let (head, tail) = rest.split_at(start);
out.push_str(&code_spans(head));
if let Some(close) = tail.find(']')
&& tail[close + 1..].starts_with('(')
&& let Some(end) = tail[close + 2..].find(')')
{
let label = &tail[1..close];
let link = &tail[close + 2..close + 2 + end];
let href = if link.ends_with("MAP.md") {
check::resolve_link(scope, link)
.map(|s| format!("#{}", anchor(&s)))
.unwrap_or_else(|| esc(link))
} else {
esc(link)
};
let _ = write!(out, "<a href=\"{href}\">{}</a>", code_spans(label));
rest = &tail[close + 3 + end..];
} else {
out.push('[');
rest = &tail[1..];
}
}
out.push_str(&code_spans(rest));
out
}
fn code_spans(text: &str) -> String {
let mut out = String::new();
for (i, part) in text.split('`').enumerate() {
if i % 2 == 1 {
let _ = write!(out, "<code>{}</code>", esc(part));
} else {
out.push_str(&esc(part));
}
}
out
}
fn body_html(body: &str, scope: &str) -> String {
let mut out = String::new();
let mut in_list = false;
let mut in_slot = false;
for line in body.lines() {
if line.starts_with("<!-- radar:slot") {
in_slot = true;
continue;
}
if line.starts_with("<!-- /radar:slot") {
in_slot = false;
continue;
}
if in_slot {
let _ = writeln!(out, "<p class=\"purpose\">{}</p>", inline(line, scope));
continue;
}
if let Some(h) = line.strip_prefix("## ") {
if in_list {
out.push_str("</ul>\n");
in_list = false;
}
let _ = writeln!(out, "<h3>{}</h3>", esc(h));
} else if line.starts_with("# ") {
} else if let Some(item) = line.strip_prefix("- ") {
if !in_list {
out.push_str("<ul>\n");
in_list = true;
}
let _ = writeln!(out, "<li>{}</li>", inline(item, scope));
} else if line.trim().is_empty() {
if in_list {
out.push_str("</ul>\n");
in_list = false;
}
} else {
let _ = writeln!(out, "<p>{}</p>", inline(line, scope));
}
}
if in_list {
out.push_str("</ul>\n");
}
out
}
fn graph_svg(report: &CheckReport) -> String {
let maps = &report.maps;
let parent = |scope: &str| -> Option<String> {
let mut cur = scope.to_string();
loop {
let p = match cur.rfind('/') {
Some(i) => cur[..i].to_string(),
None if !cur.is_empty() => String::new(),
None => return None,
};
if maps.contains_key(&p) {
return Some(p);
}
if p.is_empty() {
return None;
}
cur = p;
}
};
let mut kids: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for scope in maps.keys() {
if !scope.is_empty()
&& let Some(p) = parent(scope)
{
kids.entry(p).or_default().push(scope.clone());
}
}
let mut pos: std::collections::BTreeMap<String, (f64, f64)> = Default::default();
let mut next_y = 0.0f64;
fn place(
scope: &str,
depth: usize,
kids: &std::collections::BTreeMap<String, Vec<String>>,
pos: &mut std::collections::BTreeMap<String, (f64, f64)>,
next_y: &mut f64,
) -> f64 {
let children = kids.get(scope).cloned().unwrap_or_default();
let y = if children.is_empty() {
let y = *next_y;
*next_y += 34.0;
y
} else {
let ys: Vec<f64> = children
.iter()
.map(|c| place(c, depth + 1, kids, pos, next_y))
.collect();
ys.iter().sum::<f64>() / ys.len() as f64
};
pos.insert(scope.to_string(), (depth as f64 * 220.0 + 20.0, y + 20.0));
y
}
if maps.contains_key("") {
place("", 0, &kids, &mut pos, &mut next_y);
}
let height = (next_y + 60.0).max(80.0);
let width = pos.values().map(|(x, _)| x + 220.0).fold(320.0, f64::max);
let mut svg = format!(
"<svg viewBox=\"0 0 {width:.0} {height:.0}\" xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-label=\"map topology\">"
);
for (scope, (x, y)) in &pos {
if let Some(p) = parent(scope)
&& let Some((px, py)) = pos.get(&p)
{
let _ = write!(
svg,
"<path d=\"M{:.0},{:.0} C{:.0},{:.0} {:.0},{:.0} {:.0},{:.0}\" class=\"edge\"/>",
px + 150.0,
py + 12.0,
px + 185.0,
py + 12.0,
x - 35.0,
y + 12.0,
*x,
y + 12.0
);
}
}
for (scope, (x, y)) in &pos {
let label = if scope.is_empty() { "." } else { scope };
let stale = report.stale.get(scope).copied().unwrap_or(false);
let _ = write!(
svg,
"<a href=\"#{}\"><g class=\"node{}\"><rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"150\" height=\"24\" rx=\"6\"/><text x=\"{:.0}\" y=\"{:.0}\">{}</text></g></a>",
anchor(scope),
if stale { " stale" } else { "" },
x + 8.0,
y + 16.0,
esc(label)
);
}
svg.push_str("</svg>");
svg
}
const CSS: &str = r#"
:root { --bg:#ffffff; --fg:#1f2328; --muted:#59636e; --border:#d1d9e0; --accent:#0969da; --code:#f6f8fa; --stale:#d1242f; }
@media (prefers-color-scheme: dark) {
:root { --bg:#0d1117; --fg:#f0f6fc; --muted:#9198a1; --border:#3d444d; --accent:#4493f8; --code:#151b23; --stale:#ff7b72; }
}
* { box-sizing: border-box; }
body { margin:0 auto; max-width:920px; padding:2rem 1.5rem 4rem; background:var(--bg); color:var(--fg);
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }
h1 { font-size:1.7rem; border-bottom:1px solid var(--border); padding-bottom:.4rem; }
h2 { font-size:1.25rem; margin-top:2.4rem; border-bottom:1px solid var(--border); padding-bottom:.3rem; }
h3 { font-size:1rem; margin:1.2rem 0 .3rem; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; font-size:.8rem; }
code { background:var(--code); border:1px solid var(--border); border-radius:6px; padding:.1em .35em; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
ul { padding-left:1.4rem; margin:.3rem 0 .8rem; } li { margin:.15rem 0; }
.purpose { font-style:italic; color:var(--muted); margin:.4rem 0 1rem; }
.meta { font-size:.8rem; color:var(--muted); margin:-.2rem 0 .6rem; }
.meta .stale { color:var(--stale); font-weight:600; }
.graph { overflow-x:auto; border:1px solid var(--border); border-radius:8px; padding:.8rem; margin:1.2rem 0; }
svg { display:block; min-width:100%; }
svg rect { fill:var(--code); stroke:var(--border); }
svg .stale rect { stroke:var(--stale); }
svg text { fill:var(--fg); font:12px ui-monospace,Menlo,monospace; }
svg .edge { fill:none; stroke:var(--border); stroke-width:1.5; }
#filter { width:100%; padding:.5rem .8rem; font-size:.95rem; border:1px solid var(--border);
border-radius:8px; background:var(--bg); color:var(--fg); margin:1rem 0; }
footer { margin-top:3rem; padding-top:1rem; border-top:1px solid var(--border); font-size:.8rem; color:var(--muted); }
"#;
const JS: &str = r#"
const input = document.getElementById('filter');
input.addEventListener('input', () => {
const q = input.value.toLowerCase();
document.querySelectorAll('section.map').forEach(s => {
s.style.display = !q || s.textContent.toLowerCase().includes(q) ? '' : 'none';
});
});
"#;
pub fn render(root: &Path, report: &CheckReport, repo_name: &str) -> String {
let mut sections = String::new();
for (scope, map) in &report.maps {
let path = crate::mapfile::map_path(root, scope);
let Ok(doc) = std::fs::read_to_string(&path) else {
continue;
};
let Some((fm, body)) = crate::frontmatter::parse(&doc) else {
continue;
};
let title = if scope.is_empty() { "." } else { scope };
let stale = report.stale.get(scope).copied().unwrap_or(false);
let _ = write!(
sections,
"<section class=\"map\" id=\"{}\"><h2><code>{}</code></h2>\
<p class=\"meta\">fidelity: {} · {} · stamped {}{}</p>\n{}</section>\n",
anchor(scope),
esc(title),
esc(&map.fidelity),
esc(fm.get("tokens").unwrap_or("~?")),
esc(fm.get("stamped").unwrap_or("?")),
if stale {
" · <span class=\"stale\">STALE</span>"
} else {
""
},
body_html(body, scope)
);
}
let generated = crate::mapfile::now_iso();
format!(
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
<meta name=\"generator\" content=\"radar {} - https://github.com/sanix-darker/radar\">\
<title>{} - radar knowledge base</title><style>{CSS}</style></head><body>\n\
<h1>{} - knowledge base</h1>\n\
<p class=\"meta\">{} map(s) · generated {generated} by radar {}</p>\n\
<div class=\"graph\">{}</div>\n\
<input id=\"filter\" type=\"search\" placeholder=\"filter maps…\" aria-label=\"filter\">\n\
{sections}\
<footer>Generated by <strong>radar {}</strong> on {generated} - maps are routers, not truth; verify at the destination.</footer>\n\
<script>{JS}</script></body></html>\n",
crate::VERSION,
esc(repo_name),
esc(repo_name),
report.maps.len(),
crate::VERSION,
graph_svg(report),
crate::VERSION,
)
}
pub fn serve_web(root: &std::path::Path, addr: &str, repo_name: &str) -> std::io::Result<()> {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind(addr)?;
let local = listener.local_addr()?;
println!("radar serve --web → http://{local}/ (Ctrl-C to stop)");
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let head = String::from_utf8_lossy(&buf);
if !head.starts_with("GET ") {
let _ = stream.write_all(b"HTTP/1.1 405 Method Not Allowed\r\nAllow: GET\r\n\r\n");
continue;
}
let cache = crate::cache::ScanCache::load(root);
let report = crate::check::check(root, &cache);
let html = render(root, &report, repo_name);
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\n\r\n",
html.len()
);
let _ = stream.write_all(html.as_bytes());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_hostile_content() {
assert_eq!(esc("<script>"), "<script>");
let html = body_html("- `<img onerror=x>` [a](<bad>)\n", "");
assert!(!html.contains("<img"), "{html}");
assert!(html.contains("<img"), "{html}");
}
#[test]
fn map_links_become_anchors() {
let html = body_html("- [jwt/](jwt/MAP.md)\n", "auth");
assert!(html.contains("href=\"#auth-jwt\""), "{html}");
}
#[test]
fn inline_code_renders() {
let html = body_html("`sig` text\n", "");
assert!(html.contains("<code>sig</code>"), "{html}");
}
}