1#![allow(clippy::format_push_string)]
9
10use std::fmt::Write as _;
11
12use crate::domain::Jurisdiction;
13use crate::output::{CaseOutput, NodeOutput, RelOutput};
14use crate::parser::SourceEntry;
15use sha2::{Digest, Sha256};
16
17#[derive(Debug, Default, Clone)]
19pub struct HtmlConfig {
20 pub thumbnail_base_url: Option<String>,
28}
29
30const THUMB_KEY_HEX_LEN: usize = 32;
32
33const MAX_FRAGMENT_BYTES: usize = 512_000;
35
36pub fn render_case(case: &CaseOutput, config: &HtmlConfig) -> Result<String, String> {
42 let mut html = String::with_capacity(8192);
43
44 let og_title = truncate(&case.title, 120);
45 let og_description = build_case_og_description(case);
46
47 html.push_str(&format!(
49 "<article class=\"loom-case\" itemscope itemtype=\"https://schema.org/Article\" \
50 data-og-title=\"{}\" \
51 data-og-description=\"{}\" \
52 data-og-type=\"article\" \
53 data-og-url=\"/{}\"{}>\n",
54 escape_attr(&og_title),
55 escape_attr(&og_description),
56 escape_attr(case.slug.as_deref().unwrap_or(&case.case_id)),
57 og_image_attr(case_hero_image(case).as_deref(), config),
58 ));
59
60 let country = case
62 .slug
63 .as_deref()
64 .and_then(extract_country_from_case_slug);
65 render_case_header(&mut html, case, country.as_deref());
66
67 render_financial_details(&mut html, &case.relationships, &case.nodes);
69
70 render_sources(&mut html, &case.sources);
72
73 let people: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "person").collect();
75 if !people.is_empty() {
76 render_entity_section(&mut html, "People", &people, config);
77 }
78
79 let orgs: Vec<&NodeOutput> = case
81 .nodes
82 .iter()
83 .filter(|n| n.label == "organization")
84 .collect();
85 if !orgs.is_empty() {
86 render_entity_section(&mut html, "Organizations", &orgs, config);
87 }
88
89 let mut events: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "event").collect();
91 events.sort_by(|a, b| a.occurred_at.cmp(&b.occurred_at));
92 if !events.is_empty() {
93 render_timeline(&mut html, &events);
94 }
95
96 render_related_cases(&mut html, &case.relationships, &case.nodes);
98
99 render_case_json_ld(&mut html, case);
101
102 html.push_str("</article>\n");
103
104 if html.len() > MAX_FRAGMENT_BYTES {
105 return Err(format!(
106 "HTML fragment exceeds {MAX_FRAGMENT_BYTES} bytes ({} bytes)",
107 html.len()
108 ));
109 }
110
111 Ok(html)
112}
113
114pub fn render_person(
120 node: &NodeOutput,
121 cases: &[(String, String)], config: &HtmlConfig,
123) -> Result<String, String> {
124 let mut html = String::with_capacity(4096);
125
126 let og_title = truncate(&node.name, 120);
127 let og_description = build_person_og_description(node);
128
129 html.push_str(&format!(
130 "<article class=\"loom-person\" itemscope itemtype=\"https://schema.org/Person\" \
131 data-og-title=\"{}\" \
132 data-og-description=\"{}\" \
133 data-og-type=\"profile\" \
134 data-og-url=\"/{}\"{}>\n",
135 escape_attr(&og_title),
136 escape_attr(&og_description),
137 escape_attr(node.slug.as_deref().unwrap_or(&node.id)),
138 og_image_attr(node.thumbnail.as_deref(), config),
139 ));
140
141 render_entity_detail(&mut html, node, config);
142 render_cases_list(&mut html, cases);
143 render_person_json_ld(&mut html, node);
144
145 html.push_str("</article>\n");
146
147 check_size(&html)
148}
149
150pub fn render_organization(
156 node: &NodeOutput,
157 cases: &[(String, String)],
158 config: &HtmlConfig,
159) -> Result<String, String> {
160 let mut html = String::with_capacity(4096);
161
162 let og_title = truncate(&node.name, 120);
163 let og_description = build_org_og_description(node);
164
165 html.push_str(&format!(
166 "<article class=\"loom-organization\" itemscope itemtype=\"https://schema.org/Organization\" \
167 data-og-title=\"{}\" \
168 data-og-description=\"{}\" \
169 data-og-type=\"profile\" \
170 data-og-url=\"/{}\"{}>\n",
171 escape_attr(&og_title),
172 escape_attr(&og_description),
173 escape_attr(node.slug.as_deref().unwrap_or(&node.id)),
174 og_image_attr(node.thumbnail.as_deref(), config),
175 ));
176
177 render_entity_detail(&mut html, node, config);
178 render_cases_list(&mut html, cases);
179 render_org_json_ld(&mut html, node);
180
181 html.push_str("</article>\n");
182
183 check_size(&html)
184}
185
186fn render_case_header(html: &mut String, case: &CaseOutput, country: Option<&str>) {
189 html.push_str(&format!(
190 " <header class=\"loom-case-header\">\n <h1 itemprop=\"headline\">{}</h1>\n",
191 escape(&case.title)
192 ));
193
194 if !case.amounts.is_empty() {
195 html.push_str(" <div class=\"loom-case-amounts\">\n");
196 for entry in &case.amounts {
197 let approx_cls = if entry.approximate {
198 " loom-amount-approx"
199 } else {
200 ""
201 };
202 let label_cls = entry
203 .label
204 .as_deref()
205 .unwrap_or("unlabeled")
206 .replace('_', "-");
207 html.push_str(&format!(
208 " <span class=\"loom-amount-badge loom-amount-{label_cls}{approx_cls}\">{}</span>\n",
209 escape(&entry.format_display())
210 ));
211 }
212 html.push_str(" </div>\n");
213 }
214
215 if !case.tags.is_empty() {
216 html.push_str(" <div class=\"loom-tags\">\n");
217 for tag in &case.tags {
218 let href = match country {
219 Some(cc) => format!("/tags/{}/{}", escape_attr(cc), escape_attr(tag)),
220 None => format!("/tags/{}", escape_attr(tag)),
221 };
222 html.push_str(&format!(
223 " <a href=\"{}\" class=\"loom-tag\">{}</a>\n",
224 href,
225 escape(tag)
226 ));
227 }
228 html.push_str(" </div>\n");
229 }
230
231 if !case.summary.is_empty() {
232 html.push_str(&format!(
233 " <p class=\"loom-summary\" itemprop=\"description\">{}</p>\n",
234 escape(&case.summary)
235 ));
236 }
237
238 html.push_str(&format!(
240 " <a href=\"/canvas/{}\" class=\"loom-canvas-link\">View on canvas</a>\n",
241 escape_attr(&case.id)
242 ));
243
244 html.push_str(" </header>\n");
245}
246
247fn render_sources(html: &mut String, sources: &[SourceEntry]) {
248 if sources.is_empty() {
249 return;
250 }
251 html.push_str(" <section class=\"loom-sources\">\n <h2>Sources</h2>\n <ol>\n");
252 for source in sources {
253 match source {
254 SourceEntry::Url(url) => {
255 html.push_str(&format!(
256 " <li><a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a></li>\n",
257 escape_attr(url),
258 escape(url)
259 ));
260 }
261 SourceEntry::Structured { url, title, .. } => {
262 let display = title.as_deref().unwrap_or(url.as_str());
263 html.push_str(&format!(
264 " <li><a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a></li>\n",
265 escape_attr(url),
266 escape(display)
267 ));
268 }
269 }
270 }
271 html.push_str(" </ol>\n </section>\n");
272}
273
274fn render_entity_section(
275 html: &mut String,
276 title: &str,
277 nodes: &[&NodeOutput],
278 config: &HtmlConfig,
279) {
280 html.push_str(&format!(
281 " <section class=\"loom-entities loom-entities-{}\">\n <h2>{title}</h2>\n <div class=\"loom-entity-cards\">\n",
282 title.to_lowercase()
283 ));
284 for node in nodes {
285 render_entity_card(html, node, config);
286 }
287 html.push_str(" </div>\n </section>\n");
288}
289
290fn render_entity_card(html: &mut String, node: &NodeOutput, config: &HtmlConfig) {
291 let schema_type = match node.label.as_str() {
292 "person" => "Person",
293 "organization" => "Organization",
294 _ => "Thing",
295 };
296 html.push_str(&format!(
297 " <div class=\"loom-entity-card\" itemscope itemtype=\"https://schema.org/{schema_type}\">\n"
298 ));
299
300 if let Some(thumb) = &node.thumbnail {
301 let thumb_url = rewrite_thumbnail_url(thumb, config);
302 html.push_str(&format!(
303 " <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail\" itemprop=\"image\" loading=\"lazy\" width=\"64\" height=\"64\" />\n",
304 escape_attr(&thumb_url),
305 escape_attr(&node.name)
306 ));
307 }
308
309 let entity_href = if let Some(slug) = &node.slug {
311 format!("/{}", escape_attr(slug))
312 } else {
313 format!("/canvas/{}", escape_attr(&node.id))
314 };
315
316 html.push_str(&format!(
317 " <div class=\"loom-entity-info\">\n \
318 <a href=\"{}\" class=\"loom-entity-name\" itemprop=\"name\">{}</a>\n",
319 entity_href,
320 escape(&node.name)
321 ));
322
323 if let Some(q) = &node.qualifier {
324 html.push_str(&format!(
325 " <span class=\"loom-qualifier\">{}</span>\n",
326 escape(q)
327 ));
328 }
329
330 match node.label.as_str() {
332 "person" => {
333 let roles: Vec<_> = node.role.iter().map(|r| format_enum(r)).collect();
334 render_dl_field(html, "Role", &roles.join(", "));
335 render_dl_opt_country(html, "Nationality", node.nationality.as_ref());
336 }
337 "organization" => {
338 render_dl_opt_formatted(html, "Type", node.org_type.as_ref());
339 if let Some(j) = &node.jurisdiction {
340 render_dl_field(html, "Jurisdiction", &format_jurisdiction(j));
341 }
342 }
343 "asset" => {
344 render_dl_opt_formatted(html, "Type", node.asset_type.as_ref());
345 if let Some(m) = &node.value {
346 render_dl_field(html, "Value", &m.display);
347 }
348 render_dl_opt_formatted(html, "Status", node.status.as_ref());
349 }
350 "document" => {
351 render_dl_opt_formatted(html, "Type", node.doc_type.as_ref());
352 render_dl_opt(html, "Issued", node.issued_at.as_ref());
353 }
354 "event" => {
355 render_dl_opt_formatted(html, "Type", node.event_type.as_ref());
356 render_dl_opt(html, "Date", node.occurred_at.as_ref());
357 }
358 _ => {}
359 }
360
361 html.push_str(" </div>\n </div>\n");
362}
363
364fn render_timeline(html: &mut String, events: &[&NodeOutput]) {
365 html.push_str(
366 " <section class=\"loom-timeline\">\n <h2>Timeline</h2>\n <ol class=\"loom-events\">\n",
367 );
368 for event in events {
369 html.push_str(" <li class=\"loom-event\">\n");
370 if let Some(date) = &event.occurred_at {
371 html.push_str(&format!(
372 " <time datetime=\"{}\" class=\"loom-event-date\">{}</time>\n",
373 escape_attr(date),
374 escape(date)
375 ));
376 }
377 html.push_str(" <div class=\"loom-event-body\">\n");
378 html.push_str(&format!(
379 " <span class=\"loom-event-name\">{}</span>\n",
380 escape(&event.name)
381 ));
382 if let Some(et) = &event.event_type {
383 html.push_str(&format!(
384 " <span class=\"loom-event-type\">{}</span>\n",
385 escape(&format_enum(et))
386 ));
387 }
388 if let Some(desc) = &event.description {
389 html.push_str(&format!(
390 " <p class=\"loom-event-description\">{}</p>\n",
391 escape(desc)
392 ));
393 }
394 html.push_str(" </div>\n");
395 html.push_str(" </li>\n");
396 }
397 html.push_str(" </ol>\n </section>\n");
398}
399
400fn render_related_cases(html: &mut String, relationships: &[RelOutput], nodes: &[NodeOutput]) {
401 let related: Vec<&RelOutput> = relationships
402 .iter()
403 .filter(|r| r.rel_type == "related_to")
404 .collect();
405 if related.is_empty() {
406 return;
407 }
408 html.push_str(
409 " <section class=\"loom-related-cases\">\n <h2>Related Cases</h2>\n <div class=\"loom-related-list\">\n",
410 );
411 for rel in &related {
412 if let Some(node) = nodes
413 .iter()
414 .find(|n| n.id == rel.target_id && n.label == "case")
415 {
416 let href = node
417 .slug
418 .as_deref()
419 .map_or_else(|| format!("/cases/{}", node.id), |s| format!("/{s}"));
420 let desc = rel.description.as_deref().unwrap_or("");
421 html.push_str(&format!(
422 " <a href=\"{}\" class=\"loom-related-card\">\n <span class=\"loom-related-title\">{}</span>\n",
423 escape_attr(&href),
424 escape(&node.name)
425 ));
426 if !desc.is_empty() {
427 html.push_str(&format!(
428 " <span class=\"loom-related-desc\">{}</span>\n",
429 escape(desc)
430 ));
431 }
432 html.push_str(" </a>\n");
433 }
434 }
435 html.push_str(" </div>\n </section>\n");
436}
437
438fn render_financial_details(html: &mut String, relationships: &[RelOutput], nodes: &[NodeOutput]) {
441 let financial: Vec<&RelOutput> = relationships
442 .iter()
443 .filter(|r| !r.amounts.is_empty())
444 .collect();
445 if financial.is_empty() {
446 return;
447 }
448
449 let node_name = |id: &str| -> String {
450 nodes
451 .iter()
452 .find(|n| n.id == id)
453 .map_or_else(|| id.to_string(), |n| n.name.clone())
454 };
455
456 html.push_str(
457 " <section class=\"loom-financial\">\n <h2>Financial Details</h2>\n <dl class=\"loom-financial-list\">\n",
458 );
459 for rel in &financial {
460 let source = node_name(&rel.source_id);
461 let target = node_name(&rel.target_id);
462 let rel_label = format_enum(&rel.rel_type);
463 html.push_str(&format!(
464 " <div class=\"loom-financial-entry\">\n <dt>{} → {} <span class=\"loom-rel-label\">{}</span></dt>\n",
465 escape(&source), escape(&target), escape(&rel_label)
466 ));
467 for entry in &rel.amounts {
468 let approx_cls = if entry.approximate {
469 " loom-amount-approx"
470 } else {
471 ""
472 };
473 html.push_str(&format!(
474 " <dd><span class=\"loom-amount-badge{}\">{}</span></dd>\n",
475 approx_cls,
476 escape(&entry.format_display())
477 ));
478 }
479 html.push_str(" </div>\n");
480 }
481 html.push_str(" </dl>\n </section>\n");
482}
483
484fn render_entity_detail(html: &mut String, node: &NodeOutput, config: &HtmlConfig) {
487 html.push_str(" <header class=\"loom-entity-header\">\n");
488
489 if let Some(thumb) = &node.thumbnail {
490 let thumb_url = rewrite_thumbnail_url(thumb, config);
491 html.push_str(&format!(
492 " <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail-large\" itemprop=\"image\" loading=\"lazy\" width=\"128\" height=\"128\" />\n",
493 escape_attr(&thumb_url),
494 escape_attr(&node.name)
495 ));
496 }
497
498 html.push_str(&format!(
499 " <h1 itemprop=\"name\">{}</h1>\n",
500 escape(&node.name)
501 ));
502
503 if let Some(q) = &node.qualifier {
504 html.push_str(&format!(
505 " <p class=\"loom-qualifier\">{}</p>\n",
506 escape(q)
507 ));
508 }
509
510 html.push_str(&format!(
511 " <a href=\"/canvas/{}\" class=\"loom-canvas-link\">View on canvas</a>\n",
512 escape_attr(&node.id)
513 ));
514 html.push_str(" </header>\n");
515
516 if let Some(desc) = &node.description {
518 html.push_str(&format!(
519 " <p class=\"loom-description\" itemprop=\"description\">{}</p>\n",
520 escape(desc)
521 ));
522 }
523
524 html.push_str(" <dl class=\"loom-fields\">\n");
526
527 match node.label.as_str() {
528 "person" => {
529 let roles: Vec<_> = node.role.iter().map(|r| format_enum(r)).collect();
530 render_dl_item(html, "Role", &roles.join(", "));
531 render_dl_opt_country_item(html, "Nationality", node.nationality.as_ref());
532 render_dl_opt_item(html, "Date of Birth", node.date_of_birth.as_ref());
533 render_dl_opt_item(html, "Place of Birth", node.place_of_birth.as_ref());
534 render_dl_opt_formatted_item(html, "Status", node.status.as_ref());
535 }
536 "organization" => {
537 render_dl_opt_formatted_item(html, "Type", node.org_type.as_ref());
538 if let Some(j) = &node.jurisdiction {
539 render_dl_item(html, "Jurisdiction", &format_jurisdiction(j));
540 }
541 render_dl_opt_item(html, "Headquarters", node.headquarters.as_ref());
542 render_dl_opt_item(html, "Founded", node.founded_date.as_ref());
543 render_dl_opt_item(html, "Registration", node.registration_number.as_ref());
544 render_dl_opt_formatted_item(html, "Status", node.status.as_ref());
545 }
546 "asset" => {
547 render_dl_opt_formatted_item(html, "Type", node.asset_type.as_ref());
548 if let Some(m) = &node.value {
549 render_dl_item(html, "Value", &m.display);
550 }
551 render_dl_opt_formatted_item(html, "Status", node.status.as_ref());
552 }
553 "document" => {
554 render_dl_opt_formatted_item(html, "Type", node.doc_type.as_ref());
555 render_dl_opt_item(html, "Issued", node.issued_at.as_ref());
556 render_dl_opt_item(html, "Issuing Authority", node.issuing_authority.as_ref());
557 render_dl_opt_item(html, "Case Number", node.case_number.as_ref());
558 }
559 "event" => {
560 render_dl_opt_formatted_item(html, "Type", node.event_type.as_ref());
561 render_dl_opt_item(html, "Date", node.occurred_at.as_ref());
562 render_dl_opt_formatted_item(html, "Severity", node.severity.as_ref());
563 if let Some(j) = &node.jurisdiction {
564 render_dl_item(html, "Jurisdiction", &format_jurisdiction(j));
565 }
566 }
567 _ => {}
568 }
569
570 html.push_str(" </dl>\n");
571
572 render_entity_supplementary(html, node);
573}
574
575fn render_entity_supplementary(html: &mut String, node: &NodeOutput) {
576 if !node.aliases.is_empty() {
577 html.push_str(" <div class=\"loom-aliases\">\n <h3>Also known as</h3>\n <p>");
578 let escaped: Vec<String> = node.aliases.iter().map(|a| escape(a)).collect();
579 html.push_str(&escaped.join(", "));
580 html.push_str("</p>\n </div>\n");
581 }
582
583 if !node.urls.is_empty() {
584 html.push_str(" <div class=\"loom-urls\">\n <h3>Links</h3>\n <p>");
585 let links: Vec<String> = node
586 .urls
587 .iter()
588 .map(|url| {
589 let label = url
590 .strip_prefix("https://")
591 .or_else(|| url.strip_prefix("http://"))
592 .unwrap_or(url)
593 .trim_end_matches('/');
594 format!(
595 "<a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a>",
596 escape_attr(url),
597 escape(label)
598 )
599 })
600 .collect();
601 html.push_str(&links.join(" · "));
602 html.push_str("</p>\n </div>\n");
603 }
604}
605
606fn render_cases_list(html: &mut String, cases: &[(String, String)]) {
607 if cases.is_empty() {
608 return;
609 }
610 html.push_str(
611 " <section class=\"loom-cases\">\n <h2>Cases</h2>\n <ul class=\"loom-case-list\">\n",
612 );
613 for (case_slug, case_title) in cases {
614 html.push_str(&format!(
615 " <li><a href=\"/{}\">{}</a></li>\n",
616 escape_attr(case_slug),
617 escape(case_title)
618 ));
619 }
620 html.push_str(" </ul>\n </section>\n");
621}
622
623fn render_case_json_ld(html: &mut String, case: &CaseOutput) {
626 let mut ld = serde_json::json!({
627 "@context": "https://schema.org",
628 "@type": "Article",
629 "headline": truncate(&case.title, 120),
630 "description": truncate(&case.summary, 200),
631 "url": format!("/{}", case.slug.as_deref().unwrap_or(&case.case_id)),
632 });
633
634 if !case.sources.is_empty() {
635 let urls: Vec<&str> = case
636 .sources
637 .iter()
638 .map(|s| match s {
639 SourceEntry::Url(u) => u.as_str(),
640 SourceEntry::Structured { url, .. } => url.as_str(),
641 })
642 .collect();
643 ld["citation"] = serde_json::json!(urls);
644 }
645
646 html.push_str(&format!(
647 " <script type=\"application/ld+json\">{}</script>\n",
648 serde_json::to_string(&ld).unwrap_or_default()
649 ));
650}
651
652fn render_person_json_ld(html: &mut String, node: &NodeOutput) {
653 let mut ld = serde_json::json!({
654 "@context": "https://schema.org",
655 "@type": "Person",
656 "name": &node.name,
657 "url": format!("/{}", node.slug.as_deref().unwrap_or(&node.id)),
658 });
659
660 if let Some(nat) = &node.nationality {
661 ld["nationality"] = serde_json::json!(nat);
662 }
663 if let Some(desc) = &node.description {
664 ld["description"] = serde_json::json!(truncate(desc, 200));
665 }
666 if let Some(thumb) = &node.thumbnail {
667 ld["image"] = serde_json::json!(thumb);
668 }
669
670 html.push_str(&format!(
671 " <script type=\"application/ld+json\">{}</script>\n",
672 serde_json::to_string(&ld).unwrap_or_default()
673 ));
674}
675
676fn render_org_json_ld(html: &mut String, node: &NodeOutput) {
677 let mut ld = serde_json::json!({
678 "@context": "https://schema.org",
679 "@type": "Organization",
680 "name": &node.name,
681 "url": format!("/{}", node.slug.as_deref().unwrap_or(&node.id)),
682 });
683
684 if let Some(desc) = &node.description {
685 ld["description"] = serde_json::json!(truncate(desc, 200));
686 }
687 if let Some(thumb) = &node.thumbnail {
688 ld["logo"] = serde_json::json!(thumb);
689 }
690
691 html.push_str(&format!(
692 " <script type=\"application/ld+json\">{}</script>\n",
693 serde_json::to_string(&ld).unwrap_or_default()
694 ));
695}
696
697pub struct TagCaseEntry {
701 pub slug: String,
703 pub title: String,
705 pub amounts: Vec<crate::domain::AmountEntry>,
707}
708
709pub fn render_tag_page(tag: &str, cases: &[TagCaseEntry]) -> Result<String, String> {
715 render_tag_page_with_path(tag, &format!("/tags/{}", escape_attr(tag)), cases)
716}
717
718pub fn render_tag_page_scoped(
719 tag: &str,
720 country: &str,
721 cases: &[TagCaseEntry],
722) -> Result<String, String> {
723 let display_tag = format!("{} ({})", tag.replace('-', " "), country.to_uppercase());
724 render_tag_page_with_path(
725 &display_tag,
726 &format!("/tags/{}/{}", escape_attr(country), escape_attr(tag)),
727 cases,
728 )
729}
730
731fn render_tag_page_with_path(
732 display: &str,
733 og_url: &str,
734 cases: &[TagCaseEntry],
735) -> Result<String, String> {
736 let mut html = String::with_capacity(2048);
737
738 let og_title = format!("Cases tagged \"{display}\"");
739
740 html.push_str(&format!(
741 "<article class=\"loom-tag-page\" \
742 data-og-title=\"{}\" \
743 data-og-description=\"{} cases tagged with {}\" \
744 data-og-type=\"website\" \
745 data-og-url=\"{}\">\n",
746 escape_attr(&og_title),
747 cases.len(),
748 escape_attr(display),
749 escape_attr(og_url),
750 ));
751
752 html.push_str(&format!(
753 " <header class=\"loom-tag-header\">\n \
754 <h1>{}</h1>\n \
755 <p class=\"loom-tag-count\">{} cases</p>\n \
756 </header>\n",
757 escape(display),
758 cases.len(),
759 ));
760
761 html.push_str(" <ul class=\"loom-case-list\">\n");
762 for entry in cases {
763 let amount_badges = if entry.amounts.is_empty() {
764 String::new()
765 } else {
766 let badges: Vec<String> = entry
767 .amounts
768 .iter()
769 .map(|a| {
770 format!(
771 " <span class=\"loom-amount-badge\">{}</span>",
772 escape(&a.format_display())
773 )
774 })
775 .collect();
776 badges.join("")
777 };
778 html.push_str(&format!(
779 " <li><a href=\"/{}\">{}</a>{}</li>\n",
780 escape_attr(&entry.slug),
781 escape(&entry.title),
782 amount_badges,
783 ));
784 }
785 html.push_str(" </ul>\n");
786
787 html.push_str("</article>\n");
788
789 check_size(&html)
790}
791
792pub fn render_sitemap(
799 cases: &[(String, String)],
800 people: &[(String, String)],
801 organizations: &[(String, String)],
802 base_url: &str,
803) -> String {
804 let mut xml = String::with_capacity(4096);
805 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
806 xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
807
808 for (slug, _) in cases {
809 xml.push_str(&format!(
810 " <url><loc>{base_url}/{}</loc></url>\n",
811 escape(slug)
812 ));
813 }
814 for (slug, _) in people {
815 xml.push_str(&format!(
816 " <url><loc>{base_url}/{}</loc></url>\n",
817 escape(slug)
818 ));
819 }
820 for (slug, _) in organizations {
821 xml.push_str(&format!(
822 " <url><loc>{base_url}/{}</loc></url>\n",
823 escape(slug)
824 ));
825 }
826
827 xml.push_str("</urlset>\n");
828 xml
829}
830
831fn build_case_og_description(case: &CaseOutput) -> String {
834 if !case.summary.is_empty() {
835 return truncate(&case.summary, 200);
836 }
837 let people_count = case.nodes.iter().filter(|n| n.label == "person").count();
838 let org_count = case
839 .nodes
840 .iter()
841 .filter(|n| n.label == "organization")
842 .count();
843 truncate(
844 &format!(
845 "{} people, {} organizations, {} connections",
846 people_count,
847 org_count,
848 case.relationships.len()
849 ),
850 200,
851 )
852}
853
854fn build_person_og_description(node: &NodeOutput) -> String {
855 let mut parts = Vec::new();
856 if let Some(q) = &node.qualifier {
857 parts.push(q.clone());
858 }
859 if !node.role.is_empty() {
860 let roles: Vec<_> = node.role.iter().map(|r| format_enum(r)).collect();
861 parts.push(roles.join(", "));
862 }
863 if let Some(nat) = &node.nationality {
864 parts.push(country_name(nat));
865 }
866 if parts.is_empty() {
867 return truncate(&node.name, 200);
868 }
869 truncate(&format!("{} — {}", node.name, parts.join(" · ")), 200)
870}
871
872fn build_org_og_description(node: &NodeOutput) -> String {
873 let mut parts = Vec::new();
874 if let Some(q) = &node.qualifier {
875 parts.push(q.clone());
876 }
877 if let Some(ot) = &node.org_type {
878 parts.push(format_enum(ot));
879 }
880 if let Some(j) = &node.jurisdiction {
881 parts.push(format_jurisdiction(j));
882 }
883 if parts.is_empty() {
884 return truncate(&node.name, 200);
885 }
886 truncate(&format!("{} — {}", node.name, parts.join(" · ")), 200)
887}
888
889fn check_size(html: &str) -> Result<String, String> {
890 if html.len() > MAX_FRAGMENT_BYTES {
891 Err(format!(
892 "HTML fragment exceeds {MAX_FRAGMENT_BYTES} bytes ({} bytes)",
893 html.len()
894 ))
895 } else {
896 Ok(html.to_string())
897 }
898}
899
900fn truncate(s: &str, max: usize) -> String {
901 if s.len() <= max {
902 s.to_string()
903 } else {
904 let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
905 format!("{truncated}...")
906 }
907}
908
909fn escape(s: &str) -> String {
910 s.replace('&', "&")
911 .replace('<', "<")
912 .replace('>', ">")
913 .replace('"', """)
914}
915
916fn escape_attr(s: &str) -> String {
917 escape(s)
918}
919
920fn rewrite_thumbnail_url(source_url: &str, config: &HtmlConfig) -> String {
928 match &config.thumbnail_base_url {
929 Some(base) => {
930 if source_url.starts_with(base.as_str()) {
932 return source_url.to_string();
933 }
934 let key = thumbnail_key(source_url);
935 format!("{base}/{key}")
936 }
937 None => source_url.to_string(),
938 }
939}
940
941fn thumbnail_key(source_url: &str) -> String {
946 let mut hasher = Sha256::new();
947 hasher.update(source_url.as_bytes());
948 let hash = hasher.finalize();
949 let hex = hex_encode(&hash);
950 format!("thumbnails/{}.webp", &hex[..THUMB_KEY_HEX_LEN])
951}
952
953fn hex_encode(bytes: &[u8]) -> String {
955 let mut s = String::with_capacity(bytes.len() * 2);
956 for b in bytes {
957 let _ = write!(s, "{b:02x}");
958 }
959 s
960}
961
962fn og_image_attr(url: Option<&str>, config: &HtmlConfig) -> String {
964 match url {
965 Some(u) if !u.is_empty() => {
966 let rewritten = rewrite_thumbnail_url(u, config);
967 format!(" data-og-image=\"{}\"", escape_attr(&rewritten))
968 }
969 _ => String::new(),
970 }
971}
972
973fn case_hero_image(case: &CaseOutput) -> Option<String> {
975 case.nodes
976 .iter()
977 .filter(|n| n.label == "person")
978 .find_map(|n| n.thumbnail.clone())
979}
980
981fn format_jurisdiction(j: &Jurisdiction) -> String {
982 let country = country_name(&j.country);
983 match &j.subdivision {
984 Some(sub) => format!("{country}, {sub}"),
985 None => country,
986 }
987}
988
989fn country_name(code: &str) -> String {
992 match code.to_uppercase().as_str() {
993 "AF" => "Afghanistan",
994 "AL" => "Albania",
995 "DZ" => "Algeria",
996 "AR" => "Argentina",
997 "AU" => "Australia",
998 "AT" => "Austria",
999 "BD" => "Bangladesh",
1000 "BE" => "Belgium",
1001 "BR" => "Brazil",
1002 "BN" => "Brunei",
1003 "KH" => "Cambodia",
1004 "CA" => "Canada",
1005 "CN" => "China",
1006 "CO" => "Colombia",
1007 "HR" => "Croatia",
1008 "CZ" => "Czech Republic",
1009 "DK" => "Denmark",
1010 "EG" => "Egypt",
1011 "FI" => "Finland",
1012 "FR" => "France",
1013 "DE" => "Germany",
1014 "GH" => "Ghana",
1015 "GR" => "Greece",
1016 "HK" => "Hong Kong",
1017 "HU" => "Hungary",
1018 "IN" => "India",
1019 "ID" => "Indonesia",
1020 "IR" => "Iran",
1021 "IQ" => "Iraq",
1022 "IE" => "Ireland",
1023 "IL" => "Israel",
1024 "IT" => "Italy",
1025 "JP" => "Japan",
1026 "KE" => "Kenya",
1027 "KR" => "South Korea",
1028 "KW" => "Kuwait",
1029 "LA" => "Laos",
1030 "LB" => "Lebanon",
1031 "MY" => "Malaysia",
1032 "MX" => "Mexico",
1033 "MM" => "Myanmar",
1034 "NL" => "Netherlands",
1035 "NZ" => "New Zealand",
1036 "NG" => "Nigeria",
1037 "NO" => "Norway",
1038 "PK" => "Pakistan",
1039 "PH" => "Philippines",
1040 "PL" => "Poland",
1041 "PT" => "Portugal",
1042 "QA" => "Qatar",
1043 "RO" => "Romania",
1044 "RU" => "Russia",
1045 "SA" => "Saudi Arabia",
1046 "SG" => "Singapore",
1047 "ZA" => "South Africa",
1048 "ES" => "Spain",
1049 "LK" => "Sri Lanka",
1050 "SE" => "Sweden",
1051 "CH" => "Switzerland",
1052 "TW" => "Taiwan",
1053 "TH" => "Thailand",
1054 "TL" => "Timor-Leste",
1055 "TR" => "Turkey",
1056 "AE" => "United Arab Emirates",
1057 "GB" => "United Kingdom",
1058 "US" => "United States",
1059 "VN" => "Vietnam",
1060 _ => return code.to_uppercase(),
1061 }
1062 .to_string()
1063}
1064
1065fn extract_country_from_case_slug(slug: &str) -> Option<String> {
1067 let parts: Vec<&str> = slug.split('/').collect();
1068 if parts.len() >= 2 {
1069 let candidate = parts[1];
1070 if candidate.len() == 2 && candidate.chars().all(|c| c.is_ascii_lowercase()) {
1071 return Some(candidate.to_string());
1072 }
1073 }
1074 None
1075}
1076
1077fn format_enum(s: &str) -> String {
1078 if let Some(custom) = s.strip_prefix("custom:") {
1079 return custom.to_string();
1080 }
1081 s.split('_')
1082 .map(|word| {
1083 let mut chars = word.chars();
1084 match chars.next() {
1085 None => String::new(),
1086 Some(c) => {
1087 let upper: String = c.to_uppercase().collect();
1088 upper + chars.as_str()
1089 }
1090 }
1091 })
1092 .collect::<Vec<_>>()
1093 .join(" ")
1094}
1095
1096fn render_dl_field(html: &mut String, label: &str, value: &str) {
1097 if !value.is_empty() {
1098 html.push_str(&format!(
1099 " <span class=\"loom-field\"><strong>{label}:</strong> {}</span>\n",
1100 escape(value)
1101 ));
1102 }
1103}
1104
1105fn render_dl_opt(html: &mut String, label: &str, value: Option<&String>) {
1106 if let Some(v) = value {
1107 render_dl_field(html, label, v);
1108 }
1109}
1110
1111fn render_dl_opt_formatted(html: &mut String, label: &str, value: Option<&String>) {
1112 if let Some(v) = value {
1113 render_dl_field(html, label, &format_enum(v));
1114 }
1115}
1116
1117fn render_dl_item(html: &mut String, label: &str, value: &str) {
1118 if !value.is_empty() {
1119 html.push_str(&format!(
1120 " <dt>{label}</dt>\n <dd>{}</dd>\n",
1121 escape(value)
1122 ));
1123 }
1124}
1125
1126fn render_dl_opt_item(html: &mut String, label: &str, value: Option<&String>) {
1127 if let Some(v) = value {
1128 render_dl_item(html, label, v);
1129 }
1130}
1131
1132fn render_dl_opt_country(html: &mut String, label: &str, value: Option<&String>) {
1133 if let Some(v) = value {
1134 render_dl_field(html, label, &country_name(v));
1135 }
1136}
1137
1138fn render_dl_opt_country_item(html: &mut String, label: &str, value: Option<&String>) {
1139 if let Some(v) = value {
1140 render_dl_item(html, label, &country_name(v));
1141 }
1142}
1143
1144fn render_dl_opt_formatted_item(html: &mut String, label: &str, value: Option<&String>) {
1145 if let Some(v) = value {
1146 render_dl_item(html, label, &format_enum(v));
1147 }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 use super::*;
1153 use crate::output::{CaseOutput, NodeOutput, RelOutput};
1154 use crate::parser::SourceEntry;
1155
1156 fn make_case() -> CaseOutput {
1157 CaseOutput {
1158 id: "01TESTCASE0000000000000000".into(),
1159 case_id: "test-case".into(),
1160 title: "Test Corruption Case".into(),
1161 summary: "A politician was caught accepting bribes.".into(),
1162 tags: vec!["bribery".into(), "government".into()],
1163 slug: None,
1164 case_type: None,
1165 amounts: vec![],
1166 status: None,
1167 nodes: vec![
1168 NodeOutput {
1169 id: "01AAA".into(),
1170 label: "person".into(),
1171 name: "John Doe".into(),
1172 slug: Some("people/id/john-doe--governor-of-test-province".into()),
1173 qualifier: Some("Governor of Test Province".into()),
1174 description: None,
1175 thumbnail: Some("https://files.example.com/thumb.webp".into()),
1176 aliases: vec![],
1177 urls: vec![],
1178 role: vec!["politician".into()],
1179 nationality: Some("ID".into()),
1180 date_of_birth: None,
1181 place_of_birth: None,
1182 status: Some("convicted".into()),
1183 org_type: None,
1184 jurisdiction: None,
1185 headquarters: None,
1186 founded_date: None,
1187 registration_number: None,
1188 event_type: None,
1189 occurred_at: None,
1190 severity: None,
1191 doc_type: None,
1192 issued_at: None,
1193 issuing_authority: None,
1194 case_number: None,
1195 case_type: None,
1196 amounts: vec![],
1197 asset_type: None,
1198 value: None,
1199 tags: vec![],
1200 },
1201 NodeOutput {
1202 id: "01BBB".into(),
1203 label: "organization".into(),
1204 name: "KPK".into(),
1205 slug: Some("organizations/id/kpk--anti-corruption-commission".into()),
1206 qualifier: Some("Anti-Corruption Commission".into()),
1207 description: None,
1208 thumbnail: None,
1209 aliases: vec![],
1210 urls: vec![],
1211 role: vec![],
1212 nationality: None,
1213 date_of_birth: None,
1214 place_of_birth: None,
1215 status: None,
1216 org_type: Some("government_agency".into()),
1217 jurisdiction: Some(Jurisdiction {
1218 country: "ID".into(),
1219 subdivision: None,
1220 }),
1221 headquarters: None,
1222 founded_date: None,
1223 registration_number: None,
1224 event_type: None,
1225 occurred_at: None,
1226 severity: None,
1227 doc_type: None,
1228 issued_at: None,
1229 issuing_authority: None,
1230 case_number: None,
1231 case_type: None,
1232 amounts: vec![],
1233 asset_type: None,
1234 value: None,
1235 tags: vec![],
1236 },
1237 NodeOutput {
1238 id: "01CCC".into(),
1239 label: "event".into(),
1240 name: "Arrest".into(),
1241 slug: None,
1242 qualifier: None,
1243 description: Some("John Doe arrested by KPK.".into()),
1244 thumbnail: None,
1245 aliases: vec![],
1246 urls: vec![],
1247 role: vec![],
1248 nationality: None,
1249 date_of_birth: None,
1250 place_of_birth: None,
1251 status: None,
1252 org_type: None,
1253 jurisdiction: None,
1254 headquarters: None,
1255 founded_date: None,
1256 registration_number: None,
1257 event_type: Some("arrest".into()),
1258 occurred_at: Some("2024-03-15".into()),
1259 severity: None,
1260 doc_type: None,
1261 issued_at: None,
1262 issuing_authority: None,
1263 case_number: None,
1264 case_type: None,
1265 amounts: vec![],
1266 asset_type: None,
1267 value: None,
1268 tags: vec![],
1269 },
1270 ],
1271 relationships: vec![RelOutput {
1272 id: "01DDD".into(),
1273 rel_type: "investigated_by".into(),
1274 source_id: "01BBB".into(),
1275 target_id: "01CCC".into(),
1276 source_urls: vec![],
1277 description: None,
1278 amounts: vec![],
1279 valid_from: None,
1280 valid_until: None,
1281 }],
1282 sources: vec![SourceEntry::Url("https://example.com/article".into())],
1283 }
1284 }
1285
1286 #[test]
1287 fn render_case_produces_valid_html() {
1288 let case = make_case();
1289 let config = HtmlConfig::default();
1290 let html = render_case(&case, &config).unwrap();
1291
1292 assert!(html.starts_with("<article"));
1293 assert!(html.ends_with("</article>\n"));
1294 assert!(html.contains("data-og-title=\"Test Corruption Case\""));
1295 assert!(html.contains("data-og-description="));
1296 assert!(html.contains("<h1 itemprop=\"headline\">Test Corruption Case</h1>"));
1297 assert!(html.contains("loom-tag"));
1298 assert!(html.contains("bribery"));
1299 assert!(html.contains("John Doe"));
1300 assert!(html.contains("KPK"));
1301 assert!(html.contains("Arrest"));
1302 assert!(html.contains("2024-03-15"));
1303 assert!(html.contains("application/ld+json"));
1304 assert!(html.contains("View on canvas"));
1306 assert!(html.contains("/canvas/01TESTCASE0000000000000000"));
1307 }
1308
1309 #[test]
1310 fn render_case_has_sources() {
1311 let case = make_case();
1312 let config = HtmlConfig::default();
1313 let html = render_case(&case, &config).unwrap();
1314 assert!(html.contains("Sources"));
1315 assert!(html.contains("https://example.com/article"));
1316 }
1317
1318 #[test]
1319 fn render_case_entity_cards_link_to_static_views() {
1320 let case = make_case();
1321 let config = HtmlConfig::default();
1322 let html = render_case(&case, &config).unwrap();
1323
1324 assert!(html.contains("href=\"/people/id/john-doe--governor-of-test-province\""));
1326 assert!(html.contains("href=\"/organizations/id/kpk--anti-corruption-commission\""));
1327 assert!(!html.contains("href=\"/canvas/01AAA\""));
1329 assert!(!html.contains("href=\"/canvas/01BBB\""));
1330 }
1331
1332 #[test]
1333 fn render_case_entity_cards_fallback_to_canvas() {
1334 let mut case = make_case();
1335 let config = HtmlConfig::default();
1336 for node in &mut case.nodes {
1338 node.slug = None;
1339 }
1340 let html = render_case(&case, &config).unwrap();
1341
1342 assert!(html.contains("href=\"/canvas/01AAA\""));
1344 assert!(html.contains("href=\"/canvas/01BBB\""));
1345 }
1346
1347 #[test]
1348 fn render_case_omits_connections_table() {
1349 let case = make_case();
1350 let config = HtmlConfig::default();
1351 let html = render_case(&case, &config).unwrap();
1352 assert!(!html.contains("Connections"));
1355 assert!(!html.contains("loom-rel-table"));
1356 }
1357
1358 #[test]
1359 fn render_person_page() {
1360 let case = make_case();
1361 let config = HtmlConfig::default();
1362 let person = &case.nodes[0];
1363 let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
1364 let html = render_person(person, &cases_list, &config).unwrap();
1365
1366 assert!(html.contains("itemtype=\"https://schema.org/Person\""));
1367 assert!(html.contains("John Doe"));
1368 assert!(html.contains("Governor of Test Province"));
1369 assert!(html.contains("/canvas/01AAA"));
1370 assert!(html.contains("Test Corruption Case"));
1371 assert!(html.contains("application/ld+json"));
1372 }
1373
1374 #[test]
1375 fn render_organization_page() {
1376 let case = make_case();
1377 let config = HtmlConfig::default();
1378 let org = &case.nodes[1];
1379 let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
1380 let html = render_organization(org, &cases_list, &config).unwrap();
1381
1382 assert!(html.contains("itemtype=\"https://schema.org/Organization\""));
1383 assert!(html.contains("KPK"));
1384 assert!(html.contains("Indonesia")); }
1386
1387 #[test]
1388 fn render_sitemap_includes_all_urls() {
1389 let cases = vec![("cases/id/corruption/2024/test-case".into(), "Case 1".into())];
1390 let people = vec![("people/id/john-doe".into(), "John".into())];
1391 let orgs = vec![("organizations/id/test-corp".into(), "Corp".into())];
1392 let xml = render_sitemap(&cases, &people, &orgs, "https://redberrythread.org");
1393
1394 assert!(xml.contains("<?xml"));
1395 assert!(xml.contains("/cases/id/corruption/2024/test-case"));
1396 assert!(xml.contains("/people/id/john-doe"));
1397 assert!(xml.contains("/organizations/id/test-corp"));
1398 }
1399
1400 #[test]
1401 fn escape_html_special_chars() {
1402 assert_eq!(escape("<script>"), "<script>");
1403 assert_eq!(escape("AT&T"), "AT&T");
1404 assert_eq!(escape("\"quoted\""), ""quoted"");
1405 }
1406
1407 #[test]
1408 fn truncate_short_string() {
1409 assert_eq!(truncate("hello", 10), "hello");
1410 }
1411
1412 #[test]
1413 fn truncate_long_string() {
1414 let long = "a".repeat(200);
1415 let result = truncate(&long, 120);
1416 assert!(result.len() <= 120);
1417 assert!(result.ends_with("..."));
1418 }
1419
1420 #[test]
1421 fn format_enum_underscore() {
1422 assert_eq!(format_enum("investigated_by"), "Investigated By");
1423 assert_eq!(format_enum("custom:Special Type"), "Special Type");
1424 }
1425
1426 #[test]
1427 fn thumbnail_key_deterministic() {
1428 let k1 = thumbnail_key("https://example.com/photo.jpg");
1429 let k2 = thumbnail_key("https://example.com/photo.jpg");
1430 assert_eq!(k1, k2);
1431 assert!(k1.starts_with("thumbnails/"));
1432 assert!(k1.ends_with(".webp"));
1433 let hex_part = k1
1435 .strip_prefix("thumbnails/")
1436 .and_then(|s| s.strip_suffix(".webp"))
1437 .unwrap_or("");
1438 assert_eq!(hex_part.len(), THUMB_KEY_HEX_LEN);
1439 }
1440
1441 #[test]
1442 fn thumbnail_key_different_urls_differ() {
1443 let k1 = thumbnail_key("https://example.com/a.jpg");
1444 let k2 = thumbnail_key("https://example.com/b.jpg");
1445 assert_ne!(k1, k2);
1446 }
1447
1448 #[test]
1449 fn rewrite_thumbnail_url_no_config() {
1450 let config = HtmlConfig::default();
1451 let result = rewrite_thumbnail_url("https://example.com/photo.jpg", &config);
1452 assert_eq!(result, "https://example.com/photo.jpg");
1453 }
1454
1455 #[test]
1456 fn rewrite_thumbnail_url_with_base() {
1457 let config = HtmlConfig {
1458 thumbnail_base_url: Some("http://files.garage.local:3902/files".into()),
1459 };
1460 let result = rewrite_thumbnail_url("https://example.com/photo.jpg", &config);
1461 assert!(result.starts_with("http://files.garage.local:3902/files/thumbnails/"));
1462 assert!(result.ends_with(".webp"));
1463 assert!(!result.contains("example.com"));
1464 }
1465
1466 #[test]
1467 fn rewrite_thumbnail_url_already_rewritten() {
1468 let config = HtmlConfig {
1469 thumbnail_base_url: Some("https://files.redberrythread.org".into()),
1470 };
1471 let already =
1472 "https://files.redberrythread.org/thumbnails/6fc3a49567393053be6138aa346fa97a.webp";
1473 let result = rewrite_thumbnail_url(already, &config);
1474 assert_eq!(
1475 result, already,
1476 "should not double-hash already-rewritten URLs"
1477 );
1478 }
1479
1480 #[test]
1481 fn render_case_rewrites_thumbnails() {
1482 let case = make_case();
1483 let config = HtmlConfig {
1484 thumbnail_base_url: Some("http://garage.local/files".into()),
1485 };
1486 let html = render_case(&case, &config).unwrap();
1487
1488 assert!(!html.contains("src=\"https://files.example.com/thumb.webp\""));
1490 assert!(html.contains("src=\"http://garage.local/files/thumbnails/"));
1492 assert!(html.contains("data-og-image=\"http://garage.local/files/thumbnails/"));
1494 }
1495
1496 #[test]
1497 fn render_person_rewrites_thumbnails() {
1498 let case = make_case();
1499 let person = &case.nodes[0];
1500 let config = HtmlConfig {
1501 thumbnail_base_url: Some("http://garage.local/files".into()),
1502 };
1503 let html = render_person(person, &[], &config).unwrap();
1504
1505 assert!(!html.contains("src=\"https://files.example.com/thumb.webp\""));
1506 assert!(html.contains("src=\"http://garage.local/files/thumbnails/"));
1507 }
1508
1509 #[test]
1510 fn render_case_with_related_cases() {
1511 let mut case = make_case();
1512 case.relationships.push(RelOutput {
1514 id: "01RELID".into(),
1515 rel_type: "related_to".into(),
1516 source_id: "01TESTCASE0000000000000000".into(),
1517 target_id: "01TARGETCASE000000000000000".into(),
1518 source_urls: vec![],
1519 description: Some("Connected bribery scandal".into()),
1520 amounts: vec![],
1521 valid_from: None,
1522 valid_until: None,
1523 });
1524 case.nodes.push(NodeOutput {
1525 id: "01TARGETCASE000000000000000".into(),
1526 label: "case".into(),
1527 name: "Target Scandal Case".into(),
1528 slug: Some("cases/id/corruption/2002/target-scandal".into()),
1529 qualifier: None,
1530 description: None,
1531 thumbnail: None,
1532 aliases: vec![],
1533 urls: vec![],
1534 role: vec![],
1535 nationality: None,
1536 date_of_birth: None,
1537 place_of_birth: None,
1538 status: None,
1539 org_type: None,
1540 jurisdiction: None,
1541 headquarters: None,
1542 founded_date: None,
1543 registration_number: None,
1544 event_type: None,
1545 occurred_at: None,
1546 severity: None,
1547 doc_type: None,
1548 issued_at: None,
1549 issuing_authority: None,
1550 case_number: None,
1551 case_type: None,
1552 amounts: vec![],
1553 asset_type: None,
1554 value: None,
1555 tags: vec![],
1556 });
1557
1558 let config = HtmlConfig::default();
1559 let html = render_case(&case, &config).unwrap();
1560
1561 assert!(html.contains("loom-related-cases"));
1562 assert!(html.contains("Related Cases"));
1563 assert!(html.contains("Target Scandal Case"));
1564 assert!(html.contains("loom-related-card"));
1565 assert!(html.contains("Connected bribery scandal"));
1566 }
1567
1568 #[test]
1569 fn render_case_without_related_cases() {
1570 let case = make_case();
1571 let config = HtmlConfig::default();
1572 let html = render_case(&case, &config).unwrap();
1573
1574 assert!(!html.contains("loom-related-cases"));
1575 }
1576}