Current section

Files

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

crates/weave-content/src/html/components.rs

//! Reusable HTML section renderers: sources, entity cards, timeline,
//! related cases, financial details, and case lists.
use crate::output::{NodeOutput, RelOutput};
use crate::parser::SourceEntry;
use super::HtmlConfig;
use super::countries::format_jurisdiction_linked;
use super::dl::{
render_dl_countries, render_dl_country_items, render_dl_field, render_dl_field_html,
render_dl_item, render_dl_item_html, render_dl_opt, render_dl_opt_formatted,
render_dl_opt_formatted_item, render_dl_opt_item,
};
use super::escape::{escape, escape_attr, format_enum};
use super::thumbnail::rewrite_thumbnail_url;
pub(crate) 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");
}
pub(crate) 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)
));
}
// Link to static view when slug is available, otherwise fall back to wall
let entity_href = if let Some(slug) = &node.slug {
format!("/{}", escape_attr(slug))
} else {
format!("/walls/{}", 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)
));
}
// Label-specific fields
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_countries(html, "Nationality", &node.nationality);
}
"organization" => {
render_dl_opt_formatted(html, "Type", node.org_type.as_ref());
if let Some(j) = &node.jurisdiction {
render_dl_field_html(html, "Jurisdiction", &format_jurisdiction_linked(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");
}
pub(crate) 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");
}
pub(crate) 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");
}
pub(crate) 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>{} &rarr; {} <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");
}
pub(crate) 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=\"/walls/{}\" class=\"loom-wall-link\">View on the wall</a>\n",
escape_attr(&node.id)
));
html.push_str(" </header>\n");
// Description
if let Some(desc) = &node.description {
html.push_str(&format!(
" <p class=\"loom-description\" itemprop=\"description\">{}</p>\n",
escape(desc)
));
}
// Fields as definition list
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_country_items(html, "Nationality", &node.nationality);
render_dl_opt_item(html, "Date of Birth", node.date_of_birth.as_ref());
render_dl_opt_item(html, "Date of Death", node.date_of_death.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(html, "Jurisdiction", &format_jurisdiction_linked(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(html, "Jurisdiction", &format_jurisdiction_linked(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");
}
}
pub(crate) 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");
}