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