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