#![allow(clippy::format_push_string)]
use crate::domain::Jurisdiction;
use crate::output::{CaseOutput, NodeOutput, RelOutput};
use crate::parser::SourceEntry;
const MAX_FRAGMENT_BYTES: usize = 512_000;
pub fn render_case(case: &CaseOutput) -> Result<String, String> {
let mut html = String::with_capacity(8192);
let og_title = truncate(&case.title, 120);
let og_description = build_case_og_description(case);
html.push_str(&format!(
"<article class=\"loom-case\" itemscope itemtype=\"https://schema.org/Article\" \
data-og-title=\"{}\" \
data-og-description=\"{}\" \
data-og-type=\"article\" \
data-og-url=\"/case/{}\">\n",
escape_attr(&og_title),
escape_attr(&og_description),
escape_attr(&case.case_id),
));
render_case_header(&mut html, case);
render_sources(&mut html, &case.sources);
let people: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "person").collect();
if !people.is_empty() {
render_entity_section(&mut html, "People", &people);
}
let orgs: Vec<&NodeOutput> = case
.nodes
.iter()
.filter(|n| n.label == "organization")
.collect();
if !orgs.is_empty() {
render_entity_section(&mut html, "Organizations", &orgs);
}
let mut events: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "event").collect();
events.sort_by(|a, b| a.occurred_at.cmp(&b.occurred_at));
if !events.is_empty() {
render_timeline(&mut html, &events);
}
if !case.relationships.is_empty() {
render_connections(&mut html, &case.relationships, &case.nodes);
}
render_case_json_ld(&mut html, case);
html.push_str("</article>\n");
if html.len() > MAX_FRAGMENT_BYTES {
return Err(format!(
"HTML fragment exceeds {MAX_FRAGMENT_BYTES} bytes ({} bytes)",
html.len()
));
}
Ok(html)
}
pub fn render_person(
node: &NodeOutput,
cases: &[(String, String)], ) -> Result<String, String> {
let mut html = String::with_capacity(4096);
let og_title = truncate(&node.name, 120);
let og_description = build_person_og_description(node);
html.push_str(&format!(
"<article class=\"loom-person\" itemscope itemtype=\"https://schema.org/Person\" \
data-og-title=\"{}\" \
data-og-description=\"{}\" \
data-og-type=\"profile\" \
data-og-url=\"/person/{}\">\n",
escape_attr(&og_title),
escape_attr(&og_description),
escape_attr(&node.id),
));
render_entity_detail(&mut html, node);
render_cases_list(&mut html, cases);
render_person_json_ld(&mut html, node);
html.push_str("</article>\n");
check_size(&html)
}
pub fn render_organization(
node: &NodeOutput,
cases: &[(String, String)],
) -> Result<String, String> {
let mut html = String::with_capacity(4096);
let og_title = truncate(&node.name, 120);
let og_description = build_org_og_description(node);
html.push_str(&format!(
"<article class=\"loom-organization\" itemscope itemtype=\"https://schema.org/Organization\" \
data-og-title=\"{}\" \
data-og-description=\"{}\" \
data-og-type=\"profile\" \
data-og-url=\"/organization/{}\">\n",
escape_attr(&og_title),
escape_attr(&og_description),
escape_attr(&node.id),
));
render_entity_detail(&mut html, node);
render_cases_list(&mut html, cases);
render_org_json_ld(&mut html, node);
html.push_str("</article>\n");
check_size(&html)
}
fn render_case_header(html: &mut String, case: &CaseOutput) {
html.push_str(&format!(
" <header class=\"loom-case-header\">\n <h1 itemprop=\"headline\">{}</h1>\n",
escape(&case.title)
));
if !case.tags.is_empty() {
html.push_str(" <div class=\"loom-tags\">\n");
for tag in &case.tags {
html.push_str(&format!(
" <a href=\"/tags/{}\" class=\"loom-tag\">{}</a>\n",
escape_attr(tag),
escape(tag)
));
}
html.push_str(" </div>\n");
}
if !case.summary.is_empty() {
html.push_str(&format!(
" <p class=\"loom-summary\" itemprop=\"description\">{}</p>\n",
escape(&case.summary)
));
}
html.push_str(" </header>\n");
}
fn render_sources(html: &mut String, sources: &[SourceEntry]) {
if sources.is_empty() {
return;
}
html.push_str(" <section class=\"loom-sources\">\n <h2>Sources</h2>\n <ol>\n");
for source in sources {
match source {
SourceEntry::Url(url) => {
html.push_str(&format!(
" <li><a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a></li>\n",
escape_attr(url),
escape(url)
));
}
SourceEntry::Structured { url, title, .. } => {
let display = title.as_deref().unwrap_or(url.as_str());
html.push_str(&format!(
" <li><a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a></li>\n",
escape_attr(url),
escape(display)
));
}
}
}
html.push_str(" </ol>\n </section>\n");
}
fn render_entity_section(html: &mut String, title: &str, nodes: &[&NodeOutput]) {
html.push_str(&format!(
" <section class=\"loom-entities loom-entities-{}\">\n <h2>{title}</h2>\n <div class=\"loom-entity-cards\">\n",
title.to_lowercase()
));
for node in nodes {
render_entity_card(html, node);
}
html.push_str(" </div>\n </section>\n");
}
fn render_entity_card(html: &mut String, node: &NodeOutput) {
let schema_type = match node.label.as_str() {
"person" => "Person",
"organization" => "Organization",
_ => "Thing",
};
html.push_str(&format!(
" <div class=\"loom-entity-card\" itemscope itemtype=\"https://schema.org/{schema_type}\">\n"
));
if let Some(thumb) = &node.thumbnail {
html.push_str(&format!(
" <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail\" itemprop=\"image\" loading=\"lazy\" width=\"64\" height=\"64\" />\n",
escape_attr(thumb),
escape_attr(&node.name)
));
}
html.push_str(&format!(
" <div class=\"loom-entity-info\">\n \
<a href=\"/canvas/{}\" class=\"loom-entity-name\" itemprop=\"name\">{}</a>\n",
escape_attr(&node.id),
escape(&node.name)
));
if let Some(q) = &node.qualifier {
html.push_str(&format!(
" <span class=\"loom-qualifier\">{}</span>\n",
escape(q)
));
}
match node.label.as_str() {
"person" => {
render_dl_field(html, "Role", &node.role.join(", "));
render_dl_opt(html, "Nationality", node.nationality.as_ref());
}
"organization" => {
render_dl_opt(html, "Type", node.org_type.as_ref());
if let Some(j) = &node.jurisdiction {
render_dl_field(html, "Jurisdiction", &format_jurisdiction(j));
}
}
_ => {}
}
html.push_str(" </div>\n </div>\n");
}
fn render_timeline(html: &mut String, events: &[&NodeOutput]) {
html.push_str(
" <section class=\"loom-timeline\">\n <h2>Timeline</h2>\n <ol class=\"loom-events\">\n",
);
for event in events {
html.push_str(" <li class=\"loom-event\">\n");
if let Some(date) = &event.occurred_at {
html.push_str(&format!(
" <time datetime=\"{}\" class=\"loom-event-date\">{}</time>\n",
escape_attr(date),
escape(date)
));
}
html.push_str(&format!(
" <span class=\"loom-event-name\">{}</span>\n",
escape(&event.name)
));
if let Some(et) = &event.event_type {
html.push_str(&format!(
" <span class=\"loom-event-type\">{}</span>\n",
escape(&format_enum(et))
));
}
if let Some(desc) = &event.description {
html.push_str(&format!(
" <p class=\"loom-event-description\">{}</p>\n",
escape(desc)
));
}
html.push_str(" </li>\n");
}
html.push_str(" </ol>\n </section>\n");
}
fn render_connections(html: &mut String, rels: &[RelOutput], nodes: &[NodeOutput]) {
html.push_str(
" <section class=\"loom-connections\">\n <h2>Connections</h2>\n \
<table class=\"loom-rel-table\">\n <thead>\n \
<tr><th>From</th><th>Type</th><th>To</th><th>Details</th></tr>\n \
</thead>\n <tbody>\n",
);
for rel in rels {
let source_name = nodes
.iter()
.find(|n| n.id == rel.source_id)
.map_or("?", |n| &n.name);
let target_name = nodes
.iter()
.find(|n| n.id == rel.target_id)
.map_or("?", |n| &n.name);
let mut details = Vec::new();
if let Some(desc) = &rel.description {
details.push(desc.clone());
}
if let Some(amt) = &rel.amount {
if let Some(cur) = &rel.currency {
details.push(format!("{amt} {cur}"));
} else {
details.push(amt.clone());
}
}
if let Some(vf) = &rel.valid_from {
details.push(format!("from {vf}"));
}
if let Some(vu) = &rel.valid_until {
details.push(format!("until {vu}"));
}
html.push_str(&format!(
" <tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
escape(source_name),
escape(&format_enum(&rel.rel_type)),
escape(target_name),
escape(&details.join("; ")),
));
}
html.push_str(" </tbody>\n </table>\n </section>\n");
}
fn render_entity_detail(html: &mut String, node: &NodeOutput) {
html.push_str(" <header class=\"loom-entity-header\">\n");
if let Some(thumb) = &node.thumbnail {
html.push_str(&format!(
" <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail-large\" itemprop=\"image\" loading=\"lazy\" width=\"128\" height=\"128\" />\n",
escape_attr(thumb),
escape_attr(&node.name)
));
}
html.push_str(&format!(
" <h1 itemprop=\"name\">{}</h1>\n",
escape(&node.name)
));
if let Some(q) = &node.qualifier {
html.push_str(&format!(
" <p class=\"loom-qualifier\">{}</p>\n",
escape(q)
));
}
html.push_str(&format!(
" <a href=\"/canvas/{}\" class=\"loom-canvas-link\">View on canvas</a>\n",
escape_attr(&node.id)
));
html.push_str(" </header>\n");
if let Some(desc) = &node.description {
html.push_str(&format!(
" <p class=\"loom-description\" itemprop=\"description\">{}</p>\n",
escape(desc)
));
}
html.push_str(" <dl class=\"loom-fields\">\n");
match node.label.as_str() {
"person" => {
render_dl_item(html, "Role", &node.role.join(", "));
render_dl_opt_item(html, "Nationality", node.nationality.as_ref());
render_dl_opt_item(html, "Date of Birth", node.date_of_birth.as_ref());
render_dl_opt_item(html, "Place of Birth", node.place_of_birth.as_ref());
render_dl_opt_item(html, "Status", node.status.as_ref());
}
"organization" => {
render_dl_opt_item(html, "Type", node.org_type.as_ref());
if let Some(j) = &node.jurisdiction {
render_dl_item(html, "Jurisdiction", &format_jurisdiction(j));
}
render_dl_opt_item(html, "Headquarters", node.headquarters.as_ref());
render_dl_opt_item(html, "Founded", node.founded_date.as_ref());
render_dl_opt_item(html, "Registration", node.registration_number.as_ref());
render_dl_opt_item(html, "Status", node.status.as_ref());
}
_ => {}
}
html.push_str(" </dl>\n");
if !node.aliases.is_empty() {
html.push_str(" <div class=\"loom-aliases\">\n <h3>Also known as</h3>\n <ul>\n");
for alias in &node.aliases {
html.push_str(&format!(" <li>{}</li>\n", escape(alias)));
}
html.push_str(" </ul>\n </div>\n");
}
}
fn render_cases_list(html: &mut String, cases: &[(String, String)]) {
if cases.is_empty() {
return;
}
html.push_str(
" <section class=\"loom-cases\">\n <h2>Cases</h2>\n <ul class=\"loom-case-list\">\n",
);
for (case_id, case_title) in cases {
html.push_str(&format!(
" <li><a href=\"/case/{}\">{}</a></li>\n",
escape_attr(case_id),
escape(case_title)
));
}
html.push_str(" </ul>\n </section>\n");
}
fn render_case_json_ld(html: &mut String, case: &CaseOutput) {
let mut ld = serde_json::json!({
"@context": "https://schema.org",
"@type": "Article",
"headline": truncate(&case.title, 120),
"description": truncate(&case.summary, 200),
"url": format!("/case/{}", case.case_id),
});
if !case.sources.is_empty() {
let urls: Vec<&str> = case
.sources
.iter()
.map(|s| match s {
SourceEntry::Url(u) => u.as_str(),
SourceEntry::Structured { url, .. } => url.as_str(),
})
.collect();
ld["citation"] = serde_json::json!(urls);
}
html.push_str(&format!(
" <script type=\"application/ld+json\">{}</script>\n",
serde_json::to_string(&ld).unwrap_or_default()
));
}
fn render_person_json_ld(html: &mut String, node: &NodeOutput) {
let mut ld = serde_json::json!({
"@context": "https://schema.org",
"@type": "Person",
"name": &node.name,
"url": format!("/person/{}", node.id),
});
if let Some(nat) = &node.nationality {
ld["nationality"] = serde_json::json!(nat);
}
if let Some(desc) = &node.description {
ld["description"] = serde_json::json!(truncate(desc, 200));
}
if let Some(thumb) = &node.thumbnail {
ld["image"] = serde_json::json!(thumb);
}
html.push_str(&format!(
" <script type=\"application/ld+json\">{}</script>\n",
serde_json::to_string(&ld).unwrap_or_default()
));
}
fn render_org_json_ld(html: &mut String, node: &NodeOutput) {
let mut ld = serde_json::json!({
"@context": "https://schema.org",
"@type": "Organization",
"name": &node.name,
"url": format!("/organization/{}", node.id),
});
if let Some(desc) = &node.description {
ld["description"] = serde_json::json!(truncate(desc, 200));
}
if let Some(thumb) = &node.thumbnail {
ld["logo"] = serde_json::json!(thumb);
}
html.push_str(&format!(
" <script type=\"application/ld+json\">{}</script>\n",
serde_json::to_string(&ld).unwrap_or_default()
));
}
pub fn render_sitemap(
cases: &[(String, String)], people: &[(String, String)], organizations: &[(String, String)], base_url: &str,
) -> String {
let mut xml = String::with_capacity(4096);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for (case_id, _) in cases {
xml.push_str(&format!(
" <url><loc>{base_url}/case/{}</loc></url>\n",
escape(case_id)
));
}
for (id, _) in people {
xml.push_str(&format!(
" <url><loc>{base_url}/person/{}</loc></url>\n",
escape(id)
));
}
for (id, _) in organizations {
xml.push_str(&format!(
" <url><loc>{base_url}/organization/{}</loc></url>\n",
escape(id)
));
}
xml.push_str("</urlset>\n");
xml
}
fn build_case_og_description(case: &CaseOutput) -> String {
if !case.summary.is_empty() {
return truncate(&case.summary, 200);
}
let people_count = case.nodes.iter().filter(|n| n.label == "person").count();
let org_count = case
.nodes
.iter()
.filter(|n| n.label == "organization")
.count();
truncate(
&format!(
"{} people, {} organizations, {} connections",
people_count,
org_count,
case.relationships.len()
),
200,
)
}
fn build_person_og_description(node: &NodeOutput) -> String {
let mut parts = Vec::new();
if let Some(q) = &node.qualifier {
parts.push(q.clone());
}
if !node.role.is_empty() {
parts.push(node.role.join(", "));
}
if let Some(nat) = &node.nationality {
parts.push(nat.clone());
}
if parts.is_empty() {
return truncate(&node.name, 200);
}
truncate(&format!("{} — {}", node.name, parts.join(" · ")), 200)
}
fn build_org_og_description(node: &NodeOutput) -> String {
let mut parts = Vec::new();
if let Some(q) = &node.qualifier {
parts.push(q.clone());
}
if let Some(ot) = &node.org_type {
parts.push(format_enum(ot));
}
if let Some(j) = &node.jurisdiction {
parts.push(format_jurisdiction(j));
}
if parts.is_empty() {
return truncate(&node.name, 200);
}
truncate(&format!("{} — {}", node.name, parts.join(" · ")), 200)
}
fn check_size(html: &str) -> Result<String, String> {
if html.len() > MAX_FRAGMENT_BYTES {
Err(format!(
"HTML fragment exceeds {MAX_FRAGMENT_BYTES} bytes ({} bytes)",
html.len()
))
} else {
Ok(html.to_string())
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{truncated}...")
}
}
fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn escape_attr(s: &str) -> String {
escape(s)
}
fn format_jurisdiction(j: &Jurisdiction) -> String {
match &j.subdivision {
Some(sub) => format!("{}, {sub}", j.country),
None => j.country.clone(),
}
}
fn format_enum(s: &str) -> String {
if let Some(custom) = s.strip_prefix("custom:") {
return custom.to_string();
}
s.replace('_', " ")
}
fn render_dl_field(html: &mut String, label: &str, value: &str) {
if !value.is_empty() {
html.push_str(&format!(
" <span class=\"loom-field\"><strong>{label}:</strong> {}</span>\n",
escape(value)
));
}
}
fn render_dl_opt(html: &mut String, label: &str, value: Option<&String>) {
if let Some(v) = value {
render_dl_field(html, label, v);
}
}
fn render_dl_item(html: &mut String, label: &str, value: &str) {
if !value.is_empty() {
html.push_str(&format!(
" <dt>{label}</dt>\n <dd>{}</dd>\n",
escape(value)
));
}
}
fn render_dl_opt_item(html: &mut String, label: &str, value: Option<&String>) {
if let Some(v) = value {
render_dl_item(html, label, v);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::{CaseOutput, NodeOutput, RelOutput};
use crate::parser::SourceEntry;
fn make_case() -> CaseOutput {
CaseOutput {
case_id: "test-case".into(),
title: "Test Corruption Case".into(),
summary: "A politician was caught accepting bribes.".into(),
tags: vec!["bribery".into(), "government".into()],
nodes: vec![
NodeOutput {
id: "01AAA".into(),
label: "person".into(),
name: "John Doe".into(),
qualifier: Some("Governor of Test Province".into()),
description: None,
thumbnail: Some("https://files.example.com/thumb.webp".into()),
aliases: vec![],
urls: vec![],
role: vec!["politician".into()],
nationality: Some("ID".into()),
date_of_birth: None,
place_of_birth: None,
status: Some("convicted".into()),
org_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
event_type: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
asset_type: None,
value: None,
tags: vec![],
},
NodeOutput {
id: "01BBB".into(),
label: "organization".into(),
name: "KPK".into(),
qualifier: Some("Anti-Corruption Commission".into()),
description: None,
thumbnail: None,
aliases: vec![],
urls: vec![],
role: vec![],
nationality: None,
date_of_birth: None,
place_of_birth: None,
status: None,
org_type: Some("government_agency".into()),
jurisdiction: Some(Jurisdiction {
country: "ID".into(),
subdivision: None,
}),
headquarters: None,
founded_date: None,
registration_number: None,
event_type: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
asset_type: None,
value: None,
tags: vec![],
},
NodeOutput {
id: "01CCC".into(),
label: "event".into(),
name: "Arrest".into(),
qualifier: None,
description: Some("John Doe arrested by KPK.".into()),
thumbnail: None,
aliases: vec![],
urls: vec![],
role: vec![],
nationality: None,
date_of_birth: None,
place_of_birth: None,
status: None,
org_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
event_type: Some("arrest".into()),
occurred_at: Some("2024-03-15".into()),
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
asset_type: None,
value: None,
tags: vec![],
},
],
relationships: vec![RelOutput {
id: "01DDD".into(),
rel_type: "investigated_by".into(),
source_id: "01BBB".into(),
target_id: "01CCC".into(),
source_urls: vec![],
description: None,
amount: None,
currency: None,
valid_from: None,
valid_until: None,
}],
sources: vec![SourceEntry::Url("https://example.com/article".into())],
}
}
#[test]
fn render_case_produces_valid_html() {
let case = make_case();
let html = render_case(&case).unwrap();
assert!(html.starts_with("<article"));
assert!(html.ends_with("</article>\n"));
assert!(html.contains("data-og-title=\"Test Corruption Case\""));
assert!(html.contains("data-og-description="));
assert!(html.contains("<h1 itemprop=\"headline\">Test Corruption Case</h1>"));
assert!(html.contains("loom-tag"));
assert!(html.contains("bribery"));
assert!(html.contains("John Doe"));
assert!(html.contains("KPK"));
assert!(html.contains("Arrest"));
assert!(html.contains("2024-03-15"));
assert!(html.contains("application/ld+json"));
}
#[test]
fn render_case_has_sources() {
let case = make_case();
let html = render_case(&case).unwrap();
assert!(html.contains("Sources"));
assert!(html.contains("https://example.com/article"));
}
#[test]
fn render_case_has_connections_table() {
let case = make_case();
let html = render_case(&case).unwrap();
assert!(html.contains("Connections"));
assert!(html.contains("investigated by"));
assert!(html.contains("<table"));
}
#[test]
fn render_person_page() {
let case = make_case();
let person = &case.nodes[0];
let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
let html = render_person(person, &cases_list).unwrap();
assert!(html.contains("itemtype=\"https://schema.org/Person\""));
assert!(html.contains("John Doe"));
assert!(html.contains("Governor of Test Province"));
assert!(html.contains("/canvas/01AAA"));
assert!(html.contains("Test Corruption Case"));
assert!(html.contains("application/ld+json"));
}
#[test]
fn render_organization_page() {
let case = make_case();
let org = &case.nodes[1];
let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
let html = render_organization(org, &cases_list).unwrap();
assert!(html.contains("itemtype=\"https://schema.org/Organization\""));
assert!(html.contains("KPK"));
assert!(html.contains("ID")); }
#[test]
fn render_sitemap_includes_all_urls() {
let cases = vec![("case-1".into(), "Case 1".into())];
let people = vec![("01AAA".into(), "John".into())];
let orgs = vec![("01BBB".into(), "Corp".into())];
let xml = render_sitemap(&cases, &people, &orgs, "https://redberrythread.org");
assert!(xml.contains("<?xml"));
assert!(xml.contains("/case/case-1"));
assert!(xml.contains("/person/01AAA"));
assert!(xml.contains("/organization/01BBB"));
}
#[test]
fn escape_html_special_chars() {
assert_eq!(escape("<script>"), "<script>");
assert_eq!(escape("AT&T"), "AT&T");
assert_eq!(escape("\"quoted\""), ""quoted"");
}
#[test]
fn truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_long_string() {
let long = "a".repeat(200);
let result = truncate(&long, 120);
assert!(result.len() <= 120);
assert!(result.ends_with("..."));
}
#[test]
fn format_enum_underscore() {
assert_eq!(format_enum("investigated_by"), "investigated by");
assert_eq!(format_enum("custom:Special Type"), "Special Type");
}
}