#![allow(clippy::format_push_string)]
use std::fmt::Write as _;
use crate::domain::Jurisdiction;
use crate::output::{CaseOutput, NodeOutput, RelOutput};
use crate::parser::SourceEntry;
use sha2::{Digest, Sha256};
#[derive(Debug, Default, Clone)]
pub struct HtmlConfig {
pub thumbnail_base_url: Option<String>,
}
const THUMB_KEY_HEX_LEN: usize = 32;
const MAX_FRAGMENT_BYTES: usize = 512_000;
pub fn render_case(case: &CaseOutput, config: &HtmlConfig) -> 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=\"/{}\"{}>\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),
));
let country = case
.slug
.as_deref()
.and_then(extract_country_from_case_slug);
render_case_header(&mut html, case, country.as_deref());
render_financial_details(&mut html, &case.relationships, &case.nodes);
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, config);
}
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);
}
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);
}
render_related_cases(&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)], config: &HtmlConfig,
) -> 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=\"/{}\"{}>\n",
escape_attr(&og_title),
escape_attr(&og_description),
escape_attr(node.slug.as_deref().unwrap_or(&node.id)),
og_image_attr(node.thumbnail.as_deref(), config),
));
render_entity_detail(&mut html, node, config);
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)],
config: &HtmlConfig,
) -> 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=\"/{}\"{}>\n",
escape_attr(&og_title),
escape_attr(&og_description),
escape_attr(node.slug.as_deref().unwrap_or(&node.id)),
og_image_attr(node.thumbnail.as_deref(), config),
));
render_entity_detail(&mut html, node, config);
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, country: Option<&str>) {
html.push_str(&format!(
" <header class=\"loom-case-header\">\n <h1 itemprop=\"headline\">{}</h1>\n",
escape(&case.title)
));
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 label_cls = entry
.label
.as_deref()
.unwrap_or("unlabeled")
.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",
escape(&case.summary)
));
}
html.push_str(&format!(
" <a href=\"/canvas/{}\" class=\"loom-canvas-link\">View on canvas</a>\n",
escape_attr(&case.id)
));
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],
config: &HtmlConfig,
) {
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, config);
}
html.push_str(" </div>\n </section>\n");
}
fn render_entity_card(html: &mut String, node: &NodeOutput, config: &HtmlConfig) {
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 {
let thumb_url = rewrite_thumbnail_url(thumb, config);
html.push_str(&format!(
" <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail\" itemprop=\"image\" loading=\"lazy\" width=\"64\" height=\"64\" />\n",
escape_attr(&thumb_url),
escape_attr(&node.name)
));
}
let entity_href = if let Some(slug) = &node.slug {
format!("/{}", escape_attr(slug))
} else {
format!("/canvas/{}", escape_attr(&node.id))
};
html.push_str(&format!(
" <div class=\"loom-entity-info\">\n \
<a href=\"{}\" class=\"loom-entity-name\" itemprop=\"name\">{}</a>\n",
entity_href,
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" => {
let roles: Vec<_> = node.role.iter().map(|r| format_enum(r)).collect();
render_dl_field(html, "Role", &roles.join(", "));
render_dl_opt_country(html, "Nationality", node.nationality.as_ref());
}
"organization" => {
render_dl_opt_formatted(html, "Type", node.org_type.as_ref());
if let Some(j) = &node.jurisdiction {
render_dl_field(html, "Jurisdiction", &format_jurisdiction(j));
}
}
"asset" => {
render_dl_opt_formatted(html, "Type", node.asset_type.as_ref());
if let Some(m) = &node.value {
render_dl_field(html, "Value", &m.display);
}
render_dl_opt_formatted(html, "Status", node.status.as_ref());
}
"document" => {
render_dl_opt_formatted(html, "Type", node.doc_type.as_ref());
render_dl_opt(html, "Issued", node.issued_at.as_ref());
}
"event" => {
render_dl_opt_formatted(html, "Type", node.event_type.as_ref());
render_dl_opt(html, "Date", node.occurred_at.as_ref());
}
_ => {}
}
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(" <div class=\"loom-event-body\">\n");
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(" </div>\n");
html.push_str(" </li>\n");
}
html.push_str(" </ol>\n </section>\n");
}
fn render_related_cases(html: &mut String, relationships: &[RelOutput], nodes: &[NodeOutput]) {
let related: Vec<&RelOutput> = relationships
.iter()
.filter(|r| r.rel_type == "related_to")
.collect();
if related.is_empty() {
return;
}
html.push_str(
" <section class=\"loom-related-cases\">\n <h2>Related Cases</h2>\n <div class=\"loom-related-list\">\n",
);
for rel in &related {
if let Some(node) = nodes
.iter()
.find(|n| n.id == rel.target_id && n.label == "case")
{
let href = node
.slug
.as_deref()
.map_or_else(|| format!("/cases/{}", node.id), |s| format!("/{s}"));
let desc = rel.description.as_deref().unwrap_or("");
html.push_str(&format!(
" <a href=\"{}\" class=\"loom-related-card\">\n <span class=\"loom-related-title\">{}</span>\n",
escape_attr(&href),
escape(&node.name)
));
if !desc.is_empty() {
html.push_str(&format!(
" <span class=\"loom-related-desc\">{}</span>\n",
escape(desc)
));
}
html.push_str(" </a>\n");
}
}
html.push_str(" </div>\n </section>\n");
}
fn render_financial_details(html: &mut String, relationships: &[RelOutput], nodes: &[NodeOutput]) {
let financial: Vec<&RelOutput> = relationships
.iter()
.filter(|r| !r.amounts.is_empty())
.collect();
if financial.is_empty() {
return;
}
let node_name = |id: &str| -> String {
nodes
.iter()
.find(|n| n.id == id)
.map_or_else(|| id.to_string(), |n| n.name.clone())
};
html.push_str(
" <section class=\"loom-financial\">\n <h2>Financial Details</h2>\n <dl class=\"loom-financial-list\">\n",
);
for rel in &financial {
let source = node_name(&rel.source_id);
let target = node_name(&rel.target_id);
let rel_label = format_enum(&rel.rel_type);
html.push_str(&format!(
" <div class=\"loom-financial-entry\">\n <dt>{} → {} <span class=\"loom-rel-label\">{}</span></dt>\n",
escape(&source), escape(&target), escape(&rel_label)
));
for entry in &rel.amounts {
let approx_cls = if entry.approximate {
" loom-amount-approx"
} else {
""
};
html.push_str(&format!(
" <dd><span class=\"loom-amount-badge{}\">{}</span></dd>\n",
approx_cls,
escape(&entry.format_display())
));
}
html.push_str(" </div>\n");
}
html.push_str(" </dl>\n </section>\n");
}
fn render_entity_detail(html: &mut String, node: &NodeOutput, config: &HtmlConfig) {
html.push_str(" <header class=\"loom-entity-header\">\n");
if let Some(thumb) = &node.thumbnail {
let thumb_url = rewrite_thumbnail_url(thumb, config);
html.push_str(&format!(
" <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail-large\" itemprop=\"image\" loading=\"lazy\" width=\"128\" height=\"128\" />\n",
escape_attr(&thumb_url),
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" => {
let roles: Vec<_> = node.role.iter().map(|r| format_enum(r)).collect();
render_dl_item(html, "Role", &roles.join(", "));
render_dl_opt_country_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_formatted_item(html, "Status", node.status.as_ref());
}
"organization" => {
render_dl_opt_formatted_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_formatted_item(html, "Status", node.status.as_ref());
}
"asset" => {
render_dl_opt_formatted_item(html, "Type", node.asset_type.as_ref());
if let Some(m) = &node.value {
render_dl_item(html, "Value", &m.display);
}
render_dl_opt_formatted_item(html, "Status", node.status.as_ref());
}
"document" => {
render_dl_opt_formatted_item(html, "Type", node.doc_type.as_ref());
render_dl_opt_item(html, "Issued", node.issued_at.as_ref());
render_dl_opt_item(html, "Issuing Authority", node.issuing_authority.as_ref());
render_dl_opt_item(html, "Case Number", node.case_number.as_ref());
}
"event" => {
render_dl_opt_formatted_item(html, "Type", node.event_type.as_ref());
render_dl_opt_item(html, "Date", node.occurred_at.as_ref());
render_dl_opt_formatted_item(html, "Severity", node.severity.as_ref());
if let Some(j) = &node.jurisdiction {
render_dl_item(html, "Jurisdiction", &format_jurisdiction(j));
}
}
_ => {}
}
html.push_str(" </dl>\n");
render_entity_supplementary(html, node);
}
fn render_entity_supplementary(html: &mut String, node: &NodeOutput) {
if !node.aliases.is_empty() {
html.push_str(" <div class=\"loom-aliases\">\n <h3>Also known as</h3>\n <p>");
let escaped: Vec<String> = node.aliases.iter().map(|a| escape(a)).collect();
html.push_str(&escaped.join(", "));
html.push_str("</p>\n </div>\n");
}
if !node.urls.is_empty() {
html.push_str(" <div class=\"loom-urls\">\n <h3>Links</h3>\n <p>");
let links: Vec<String> = node
.urls
.iter()
.map(|url| {
let label = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url)
.trim_end_matches('/');
format!(
"<a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a>",
escape_attr(url),
escape(label)
)
})
.collect();
html.push_str(&links.join(" · "));
html.push_str("</p>\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_slug, case_title) in cases {
html.push_str(&format!(
" <li><a href=\"/{}\">{}</a></li>\n",
escape_attr(case_slug),
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.slug.as_deref().unwrap_or(&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!("/{}", node.slug.as_deref().unwrap_or(&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!("/{}", node.slug.as_deref().unwrap_or(&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 struct TagCaseEntry {
pub slug: String,
pub title: String,
pub amounts: Vec<crate::domain::AmountEntry>,
}
pub fn render_tag_page(tag: &str, cases: &[TagCaseEntry]) -> Result<String, String> {
render_tag_page_with_path(tag, &format!("/tags/{}", escape_attr(tag)), cases)
}
pub fn render_tag_page_scoped(
tag: &str,
country: &str,
cases: &[TagCaseEntry],
) -> Result<String, String> {
let display_tag = format!("{} ({})", tag.replace('-', " "), country.to_uppercase());
render_tag_page_with_path(
&display_tag,
&format!("/tags/{}/{}", escape_attr(country), escape_attr(tag)),
cases,
)
}
fn render_tag_page_with_path(
display: &str,
og_url: &str,
cases: &[TagCaseEntry],
) -> Result<String, String> {
let mut html = String::with_capacity(2048);
let og_title = format!("Cases tagged \"{display}\"");
html.push_str(&format!(
"<article class=\"loom-tag-page\" \
data-og-title=\"{}\" \
data-og-description=\"{} cases tagged with {}\" \
data-og-type=\"website\" \
data-og-url=\"{}\">\n",
escape_attr(&og_title),
cases.len(),
escape_attr(display),
escape_attr(og_url),
));
html.push_str(&format!(
" <header class=\"loom-tag-header\">\n \
<h1>{}</h1>\n \
<p class=\"loom-tag-count\">{} cases</p>\n \
</header>\n",
escape(display),
cases.len(),
));
html.push_str(" <ul class=\"loom-case-list\">\n");
for entry in cases {
let amount_badges = if entry.amounts.is_empty() {
String::new()
} else {
let badges: Vec<String> = entry
.amounts
.iter()
.map(|a| {
format!(
" <span class=\"loom-amount-badge\">{}</span>",
escape(&a.format_display())
)
})
.collect();
badges.join("")
};
html.push_str(&format!(
" <li><a href=\"/{}\">{}</a>{}</li>\n",
escape_attr(&entry.slug),
escape(&entry.title),
amount_badges,
));
}
html.push_str(" </ul>\n");
html.push_str("</article>\n");
check_size(&html)
}
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 (slug, _) in cases {
xml.push_str(&format!(
" <url><loc>{base_url}/{}</loc></url>\n",
escape(slug)
));
}
for (slug, _) in people {
xml.push_str(&format!(
" <url><loc>{base_url}/{}</loc></url>\n",
escape(slug)
));
}
for (slug, _) in organizations {
xml.push_str(&format!(
" <url><loc>{base_url}/{}</loc></url>\n",
escape(slug)
));
}
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() {
let roles: Vec<_> = node.role.iter().map(|r| format_enum(r)).collect();
parts.push(roles.join(", "));
}
if let Some(nat) = &node.nationality {
parts.push(country_name(nat));
}
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 rewrite_thumbnail_url(source_url: &str, config: &HtmlConfig) -> String {
match &config.thumbnail_base_url {
Some(base) => {
if source_url.starts_with(base.as_str()) {
return source_url.to_string();
}
let key = thumbnail_key(source_url);
format!("{base}/{key}")
}
None => source_url.to_string(),
}
}
fn thumbnail_key(source_url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(source_url.as_bytes());
let hash = hasher.finalize();
let hex = hex_encode(&hash);
format!("thumbnails/{}.webp", &hex[..THUMB_KEY_HEX_LEN])
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn og_image_attr(url: Option<&str>, config: &HtmlConfig) -> String {
match url {
Some(u) if !u.is_empty() => {
let rewritten = rewrite_thumbnail_url(u, config);
format!(" data-og-image=\"{}\"", escape_attr(&rewritten))
}
_ => String::new(),
}
}
fn case_hero_image(case: &CaseOutput) -> Option<String> {
case.nodes
.iter()
.filter(|n| n.label == "person")
.find_map(|n| n.thumbnail.clone())
}
fn format_jurisdiction(j: &Jurisdiction) -> String {
let country = country_name(&j.country);
match &j.subdivision {
Some(sub) => format!("{country}, {sub}"),
None => country,
}
}
fn country_name(code: &str) -> String {
match code.to_uppercase().as_str() {
"AF" => "Afghanistan",
"AL" => "Albania",
"DZ" => "Algeria",
"AR" => "Argentina",
"AU" => "Australia",
"AT" => "Austria",
"BD" => "Bangladesh",
"BE" => "Belgium",
"BR" => "Brazil",
"BN" => "Brunei",
"KH" => "Cambodia",
"CA" => "Canada",
"CN" => "China",
"CO" => "Colombia",
"HR" => "Croatia",
"CZ" => "Czech Republic",
"DK" => "Denmark",
"EG" => "Egypt",
"FI" => "Finland",
"FR" => "France",
"DE" => "Germany",
"GH" => "Ghana",
"GR" => "Greece",
"HK" => "Hong Kong",
"HU" => "Hungary",
"IN" => "India",
"ID" => "Indonesia",
"IR" => "Iran",
"IQ" => "Iraq",
"IE" => "Ireland",
"IL" => "Israel",
"IT" => "Italy",
"JP" => "Japan",
"KE" => "Kenya",
"KR" => "South Korea",
"KW" => "Kuwait",
"LA" => "Laos",
"LB" => "Lebanon",
"MY" => "Malaysia",
"MX" => "Mexico",
"MM" => "Myanmar",
"NL" => "Netherlands",
"NZ" => "New Zealand",
"NG" => "Nigeria",
"NO" => "Norway",
"PK" => "Pakistan",
"PH" => "Philippines",
"PL" => "Poland",
"PT" => "Portugal",
"QA" => "Qatar",
"RO" => "Romania",
"RU" => "Russia",
"SA" => "Saudi Arabia",
"SG" => "Singapore",
"ZA" => "South Africa",
"ES" => "Spain",
"LK" => "Sri Lanka",
"SE" => "Sweden",
"CH" => "Switzerland",
"TW" => "Taiwan",
"TH" => "Thailand",
"TL" => "Timor-Leste",
"TR" => "Turkey",
"AE" => "United Arab Emirates",
"GB" => "United Kingdom",
"US" => "United States",
"VN" => "Vietnam",
_ => return code.to_uppercase(),
}
.to_string()
}
fn extract_country_from_case_slug(slug: &str) -> Option<String> {
let parts: Vec<&str> = slug.split('/').collect();
if parts.len() >= 2 {
let candidate = parts[1];
if candidate.len() == 2 && candidate.chars().all(|c| c.is_ascii_lowercase()) {
return Some(candidate.to_string());
}
}
None
}
fn format_enum(s: &str) -> String {
if let Some(custom) = s.strip_prefix("custom:") {
return custom.to_string();
}
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => {
let upper: String = c.to_uppercase().collect();
upper + chars.as_str()
}
}
})
.collect::<Vec<_>>()
.join(" ")
}
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_opt_formatted(html: &mut String, label: &str, value: Option<&String>) {
if let Some(v) = value {
render_dl_field(html, label, &format_enum(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);
}
}
fn render_dl_opt_country(html: &mut String, label: &str, value: Option<&String>) {
if let Some(v) = value {
render_dl_field(html, label, &country_name(v));
}
}
fn render_dl_opt_country_item(html: &mut String, label: &str, value: Option<&String>) {
if let Some(v) = value {
render_dl_item(html, label, &country_name(v));
}
}
fn render_dl_opt_formatted_item(html: &mut String, label: &str, value: Option<&String>) {
if let Some(v) = value {
render_dl_item(html, label, &format_enum(v));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::{CaseOutput, NodeOutput, RelOutput};
use crate::parser::SourceEntry;
fn make_case() -> CaseOutput {
CaseOutput {
id: "01TESTCASE0000000000000000".into(),
case_id: "test-case".into(),
title: "Test Corruption Case".into(),
summary: "A politician was caught accepting bribes.".into(),
tags: vec!["bribery".into(), "government".into()],
slug: None,
case_type: None,
amounts: vec![],
status: None,
nodes: vec![
NodeOutput {
id: "01AAA".into(),
label: "person".into(),
name: "John Doe".into(),
slug: Some("people/id/john-doe--governor-of-test-province".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,
case_type: None,
amounts: vec![],
asset_type: None,
value: None,
tags: vec![],
},
NodeOutput {
id: "01BBB".into(),
label: "organization".into(),
name: "KPK".into(),
slug: Some("organizations/id/kpk--anti-corruption-commission".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,
case_type: None,
amounts: vec![],
asset_type: None,
value: None,
tags: vec![],
},
NodeOutput {
id: "01CCC".into(),
label: "event".into(),
name: "Arrest".into(),
slug: None,
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,
case_type: None,
amounts: vec![],
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,
amounts: vec![],
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 config = HtmlConfig::default();
let html = render_case(&case, &config).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"));
assert!(html.contains("View on canvas"));
assert!(html.contains("/canvas/01TESTCASE0000000000000000"));
}
#[test]
fn render_case_has_sources() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(html.contains("Sources"));
assert!(html.contains("https://example.com/article"));
}
#[test]
fn render_case_entity_cards_link_to_static_views() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(html.contains("href=\"/people/id/john-doe--governor-of-test-province\""));
assert!(html.contains("href=\"/organizations/id/kpk--anti-corruption-commission\""));
assert!(!html.contains("href=\"/canvas/01AAA\""));
assert!(!html.contains("href=\"/canvas/01BBB\""));
}
#[test]
fn render_case_entity_cards_fallback_to_canvas() {
let mut case = make_case();
let config = HtmlConfig::default();
for node in &mut case.nodes {
node.slug = None;
}
let html = render_case(&case, &config).unwrap();
assert!(html.contains("href=\"/canvas/01AAA\""));
assert!(html.contains("href=\"/canvas/01BBB\""));
}
#[test]
fn render_case_omits_connections_table() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(!html.contains("Connections"));
assert!(!html.contains("loom-rel-table"));
}
#[test]
fn render_person_page() {
let case = make_case();
let config = HtmlConfig::default();
let person = &case.nodes[0];
let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
let html = render_person(person, &cases_list, &config).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 config = HtmlConfig::default();
let org = &case.nodes[1];
let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
let html = render_organization(org, &cases_list, &config).unwrap();
assert!(html.contains("itemtype=\"https://schema.org/Organization\""));
assert!(html.contains("KPK"));
assert!(html.contains("Indonesia")); }
#[test]
fn render_sitemap_includes_all_urls() {
let cases = vec![("cases/id/corruption/2024/test-case".into(), "Case 1".into())];
let people = vec![("people/id/john-doe".into(), "John".into())];
let orgs = vec![("organizations/id/test-corp".into(), "Corp".into())];
let xml = render_sitemap(&cases, &people, &orgs, "https://redberrythread.org");
assert!(xml.contains("<?xml"));
assert!(xml.contains("/cases/id/corruption/2024/test-case"));
assert!(xml.contains("/people/id/john-doe"));
assert!(xml.contains("/organizations/id/test-corp"));
}
#[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");
}
#[test]
fn thumbnail_key_deterministic() {
let k1 = thumbnail_key("https://example.com/photo.jpg");
let k2 = thumbnail_key("https://example.com/photo.jpg");
assert_eq!(k1, k2);
assert!(k1.starts_with("thumbnails/"));
assert!(k1.ends_with(".webp"));
let hex_part = k1
.strip_prefix("thumbnails/")
.and_then(|s| s.strip_suffix(".webp"))
.unwrap_or("");
assert_eq!(hex_part.len(), THUMB_KEY_HEX_LEN);
}
#[test]
fn thumbnail_key_different_urls_differ() {
let k1 = thumbnail_key("https://example.com/a.jpg");
let k2 = thumbnail_key("https://example.com/b.jpg");
assert_ne!(k1, k2);
}
#[test]
fn rewrite_thumbnail_url_no_config() {
let config = HtmlConfig::default();
let result = rewrite_thumbnail_url("https://example.com/photo.jpg", &config);
assert_eq!(result, "https://example.com/photo.jpg");
}
#[test]
fn rewrite_thumbnail_url_with_base() {
let config = HtmlConfig {
thumbnail_base_url: Some("http://files.garage.local:3902/files".into()),
};
let result = rewrite_thumbnail_url("https://example.com/photo.jpg", &config);
assert!(result.starts_with("http://files.garage.local:3902/files/thumbnails/"));
assert!(result.ends_with(".webp"));
assert!(!result.contains("example.com"));
}
#[test]
fn rewrite_thumbnail_url_already_rewritten() {
let config = HtmlConfig {
thumbnail_base_url: Some("https://files.redberrythread.org".into()),
};
let already =
"https://files.redberrythread.org/thumbnails/6fc3a49567393053be6138aa346fa97a.webp";
let result = rewrite_thumbnail_url(already, &config);
assert_eq!(
result, already,
"should not double-hash already-rewritten URLs"
);
}
#[test]
fn render_case_rewrites_thumbnails() {
let case = make_case();
let config = HtmlConfig {
thumbnail_base_url: Some("http://garage.local/files".into()),
};
let html = render_case(&case, &config).unwrap();
assert!(!html.contains("src=\"https://files.example.com/thumb.webp\""));
assert!(html.contains("src=\"http://garage.local/files/thumbnails/"));
assert!(html.contains("data-og-image=\"http://garage.local/files/thumbnails/"));
}
#[test]
fn render_person_rewrites_thumbnails() {
let case = make_case();
let person = &case.nodes[0];
let config = HtmlConfig {
thumbnail_base_url: Some("http://garage.local/files".into()),
};
let html = render_person(person, &[], &config).unwrap();
assert!(!html.contains("src=\"https://files.example.com/thumb.webp\""));
assert!(html.contains("src=\"http://garage.local/files/thumbnails/"));
}
#[test]
fn render_case_with_related_cases() {
let mut case = make_case();
case.relationships.push(RelOutput {
id: "01RELID".into(),
rel_type: "related_to".into(),
source_id: "01TESTCASE0000000000000000".into(),
target_id: "01TARGETCASE000000000000000".into(),
source_urls: vec![],
description: Some("Connected bribery scandal".into()),
amounts: vec![],
valid_from: None,
valid_until: None,
});
case.nodes.push(NodeOutput {
id: "01TARGETCASE000000000000000".into(),
label: "case".into(),
name: "Target Scandal Case".into(),
slug: Some("cases/id/corruption/2002/target-scandal".into()),
qualifier: None,
description: None,
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: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
case_type: None,
amounts: vec![],
asset_type: None,
value: None,
tags: vec![],
});
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(html.contains("loom-related-cases"));
assert!(html.contains("Related Cases"));
assert!(html.contains("Target Scandal Case"));
assert!(html.contains("loom-related-card"));
assert!(html.contains("Connected bribery scandal"));
}
#[test]
fn render_case_without_related_cases() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(!html.contains("loom-related-cases"));
}
}