Packages
rbt_weave
0.2.64
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/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>{} → {} <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");
}