Packages
rbt_weave
0.2.67
0.2.67
0.2.66
0.2.65
0.2.64
0.2.63
0.2.62
0.2.61
0.2.60
0.2.59
0.2.58
0.2.57
0.2.56
0.2.55
0.2.54
0.2.53
0.2.52
0.2.51
0.2.50
0.2.49
0.2.48
0.2.47
0.2.46
0.2.45
0.2.44
0.2.43
0.2.42
0.2.41
0.2.40
0.2.39
0.2.38
0.2.37
0.2.36
0.2.35
0.2.34
0.2.33
0.2.32
0.2.31
0.2.30
0.2.29
0.2.28
0.2.27
0.2.26
0.2.25
0.2.24
0.2.23
0.2.22
0.2.21
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Graph conflict detection and image processing NIFs for OSINT knowledge graphs, powered by Rust.
Current section
Files
Jump to
Current section
Files
crates/weave-content/src/html/case.rs
//! Case page HTML fragment renderer.
use crate::output::{CaseOutput, NodeOutput};
use super::HtmlConfig;
use super::components::{
render_entity_section, render_financial_details, render_related_cases, render_sources,
render_timeline,
};
use super::countries::{country_name, extract_country_from_case_slug};
use super::escape::{escape, escape_attr, render_inline_markdown, truncate};
use super::jsonld::{build_case_og_description, render_case_json_ld};
use super::thumbnail::og_image_attr;
/// Find the first person thumbnail in a case to use as hero image.
fn case_hero_image(case: &CaseOutput) -> Option<String> {
case.nodes
.iter()
.filter(|n| n.label == "person")
.find_map(|n| n.thumbnail.clone())
}
/// Generate a complete case page HTML fragment.
///
/// # Errors
///
/// Returns an error if the rendered HTML exceeds the size limit.
pub fn render_case(case: &CaseOutput, config: &HtmlConfig) -> Result<String, String> {
let mut html = String::with_capacity(8192);
let country_code = case
.slug
.as_deref()
.and_then(extract_country_from_case_slug);
let og_title = match &country_code {
Some(cc) => truncate(&format!("{} — {}", case.title, country_name(cc)), 120),
None => truncate(&case.title, 120),
};
let og_description = build_case_og_description(case);
let og_tagline = case
.tagline
.as_deref()
.map(|t| format!(" data-og-tagline=\"{}\"", escape_attr(t)))
.unwrap_or_default();
// Root element with OG data attributes
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=\"/{}\"{}{}>\n",
escape_attr(&og_title),
escape_attr(&og_description),
escape_attr(case.slug.as_deref().unwrap_or(&case.case_id)),
og_image_attr(case_hero_image(case).as_deref(), config),
og_tagline,
));
// Header
render_case_header(&mut html, case, country_code.as_deref());
// Financial details — prominent position right after header
render_financial_details(&mut html, &case.relationships, &case.nodes);
// Sources
render_sources(&mut html, &case.sources);
// People section
let people: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "person").collect();
if !people.is_empty() {
render_entity_section(&mut html, "People", &people, config);
}
// Organizations section
let orgs: Vec<&NodeOutput> = case
.nodes
.iter()
.filter(|n| n.label == "organization")
.collect();
if !orgs.is_empty() {
render_entity_section(&mut html, "Organizations", &orgs, config);
}
// Timeline section (events sorted by occurred_at)
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);
}
// Related Cases section
render_related_cases(&mut html, &case.relationships, &case.nodes);
// JSON-LD
render_case_json_ld(&mut html, case);
html.push_str("</article>\n");
super::check_size(&html)
}
fn render_case_header(html: &mut String, case: &CaseOutput, country: Option<&str>) {
html.push_str(&format!(
" <header class=\"loom-case-header\">\n <h1 itemprop=\"headline\">{}</h1>\n",
escape(&case.title)
));
if let Some(cc) = country {
html.push_str(&format!(
" <a href=\"/countries/{}\" class=\"loom-country-badge\">{}</a>\n",
escape_attr(cc),
escape(&country_name(cc))
));
}
if let Some(tagline) = &case.tagline {
html.push_str(&format!(
" <blockquote class=\"loom-tagline\">{}</blockquote>\n",
escape(tagline)
));
}
if !case.amounts.is_empty() {
html.push_str(" <div class=\"loom-case-amounts\">\n");
for entry in &case.amounts {
let approx_cls = if entry.approximate {
" loom-amount-approx"
} else {
""
};
let raw_label = entry.label.as_deref().unwrap_or("unlabeled");
let label_cls = raw_label
.strip_prefix("custom:")
.unwrap_or(raw_label)
.replace('_', "-");
html.push_str(&format!(
" <span class=\"loom-amount-badge loom-amount-{label_cls}{approx_cls}\">{}</span>\n",
escape(&entry.format_display())
));
}
html.push_str(" </div>\n");
}
if !case.tags.is_empty() {
html.push_str(" <div class=\"loom-tags\">\n");
for tag in &case.tags {
let href = match country {
Some(cc) => format!("/tags/{}/{}", escape_attr(cc), escape_attr(tag)),
None => format!("/tags/{}", escape_attr(tag)),
};
html.push_str(&format!(
" <a href=\"{}\" class=\"loom-tag\">{}</a>\n",
href,
escape(tag)
));
}
html.push_str(" </div>\n");
}
if !case.summary.is_empty() {
html.push_str(&format!(
" <p class=\"loom-summary\" itemprop=\"description\">{}</p>\n",
render_inline_markdown(&case.summary)
));
}
// Wall link for the case node
html.push_str(&format!(
" <a href=\"/walls/{}\" class=\"loom-wall-link\">View on the wall</a>\n",
escape_attr(&case.id)
));
html.push_str(" </header>\n");
}