Current section

Files

Jump to
rbt_weave crates weave-content src html case.rs
Raw

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");
}