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 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 let mut s = String::with_capacity(bytes.len() * 2);
919 for b in bytes {
920 let _ = write!(s, "{b:02x}");
921 }
922 s
923}
924
925fn og_image_attr(url: Option<&str>, config: &HtmlConfig) -> String {
927 match url {
928 Some(u) if !u.is_empty() => {
929 let rewritten = rewrite_thumbnail_url(u, config);
930 format!(" data-og-image=\"{}\"", escape_attr(&rewritten))
931 }
932 _ => String::new(),
933 }
934}
935
936fn case_hero_image(case: &CaseOutput) -> Option<String> {
938 case.nodes
939 .iter()
940 .filter(|n| n.label == "person")
941 .find_map(|n| n.thumbnail.clone())
942}
943
944fn format_jurisdiction(j: &Jurisdiction) -> String {
945 let country = country_name(&j.country);
946 match &j.subdivision {
947 Some(sub) => format!("{country}, {sub}"),
948 None => country,
949 }
950}
951
952fn country_name(code: &str) -> String {
955 match code.to_uppercase().as_str() {
956 "AF" => "Afghanistan",
957 "AL" => "Albania",
958 "DZ" => "Algeria",
959 "AR" => "Argentina",
960 "AU" => "Australia",
961 "AT" => "Austria",
962 "BD" => "Bangladesh",
963 "BE" => "Belgium",
964 "BR" => "Brazil",
965 "BN" => "Brunei",
966 "KH" => "Cambodia",
967 "CA" => "Canada",
968 "CN" => "China",
969 "CO" => "Colombia",
970 "HR" => "Croatia",
971 "CZ" => "Czech Republic",
972 "DK" => "Denmark",
973 "EG" => "Egypt",
974 "FI" => "Finland",
975 "FR" => "France",
976 "DE" => "Germany",
977 "GH" => "Ghana",
978 "GR" => "Greece",
979 "HK" => "Hong Kong",
980 "HU" => "Hungary",
981 "IN" => "India",
982 "ID" => "Indonesia",
983 "IR" => "Iran",
984 "IQ" => "Iraq",
985 "IE" => "Ireland",
986 "IL" => "Israel",
987 "IT" => "Italy",
988 "JP" => "Japan",
989 "KE" => "Kenya",
990 "KR" => "South Korea",
991 "KW" => "Kuwait",
992 "LA" => "Laos",
993 "LB" => "Lebanon",
994 "MY" => "Malaysia",
995 "MX" => "Mexico",
996 "MM" => "Myanmar",
997 "NL" => "Netherlands",
998 "NZ" => "New Zealand",
999 "NG" => "Nigeria",
1000 "NO" => "Norway",
1001 "PK" => "Pakistan",
1002 "PH" => "Philippines",
1003 "PL" => "Poland",
1004 "PT" => "Portugal",
1005 "QA" => "Qatar",
1006 "RO" => "Romania",
1007 "RU" => "Russia",
1008 "SA" => "Saudi Arabia",
1009 "SG" => "Singapore",
1010 "ZA" => "South Africa",
1011 "ES" => "Spain",
1012 "LK" => "Sri Lanka",
1013 "SE" => "Sweden",
1014 "CH" => "Switzerland",
1015 "TW" => "Taiwan",
1016 "TH" => "Thailand",
1017 "TL" => "Timor-Leste",
1018 "TR" => "Turkey",
1019 "AE" => "United Arab Emirates",
1020 "GB" => "United Kingdom",
1021 "US" => "United States",
1022 "VN" => "Vietnam",
1023 _ => return code.to_uppercase(),
1024 }
1025 .to_string()
1026}
1027
1028fn extract_country_from_case_slug(slug: &str) -> Option<String> {
1030 let parts: Vec<&str> = slug.split('/').collect();
1031 if parts.len() >= 2 {
1032 let candidate = parts[1];
1033 if candidate.len() == 2 && candidate.chars().all(|c| c.is_ascii_lowercase()) {
1034 return Some(candidate.to_string());
1035 }
1036 }
1037 None
1038}
1039
1040fn format_enum(s: &str) -> String {
1041 if let Some(custom) = s.strip_prefix("custom:") {
1042 return custom.to_string();
1043 }
1044 s.split('_')
1045 .map(|word| {
1046 let mut chars = word.chars();
1047 match chars.next() {
1048 None => String::new(),
1049 Some(c) => {
1050 let upper: String = c.to_uppercase().collect();
1051 upper + chars.as_str()
1052 }
1053 }
1054 })
1055 .collect::<Vec<_>>()
1056 .join(" ")
1057}
1058
1059fn render_dl_field(html: &mut String, label: &str, value: &str) {
1060 if !value.is_empty() {
1061 html.push_str(&format!(
1062 " <span class=\"loom-field\"><strong>{label}:</strong> {}</span>\n",
1063 escape(value)
1064 ));
1065 }
1066}
1067
1068fn render_dl_opt(html: &mut String, label: &str, value: Option<&String>) {
1069 if let Some(v) = value {
1070 render_dl_field(html, label, v);
1071 }
1072}
1073
1074fn render_dl_opt_formatted(html: &mut String, label: &str, value: Option<&String>) {
1075 if let Some(v) = value {
1076 render_dl_field(html, label, &format_enum(v));
1077 }
1078}
1079
1080fn render_dl_item(html: &mut String, label: &str, value: &str) {
1081 if !value.is_empty() {
1082 html.push_str(&format!(
1083 " <dt>{label}</dt>\n <dd>{}</dd>\n",
1084 escape(value)
1085 ));
1086 }
1087}
1088
1089fn render_dl_opt_item(html: &mut String, label: &str, value: Option<&String>) {
1090 if let Some(v) = value {
1091 render_dl_item(html, label, v);
1092 }
1093}
1094
1095fn render_dl_opt_country(html: &mut String, label: &str, value: Option<&String>) {
1096 if let Some(v) = value {
1097 render_dl_field(html, label, &country_name(v));
1098 }
1099}
1100
1101fn render_dl_opt_country_item(html: &mut String, label: &str, value: Option<&String>) {
1102 if let Some(v) = value {
1103 render_dl_item(html, label, &country_name(v));
1104 }
1105}
1106
1107fn render_dl_opt_formatted_item(html: &mut String, label: &str, value: Option<&String>) {
1108 if let Some(v) = value {
1109 render_dl_item(html, label, &format_enum(v));
1110 }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116 use crate::output::{CaseOutput, NodeOutput, RelOutput};
1117 use crate::parser::SourceEntry;
1118
1119 fn make_case() -> CaseOutput {
1120 CaseOutput {
1121 id: "01TESTCASE0000000000000000".into(),
1122 case_id: "test-case".into(),
1123 title: "Test Corruption Case".into(),
1124 summary: "A politician was caught accepting bribes.".into(),
1125 tags: vec!["bribery".into(), "government".into()],
1126 slug: None,
1127 case_type: None,
1128 amounts: vec![],
1129 status: None,
1130 nodes: vec![
1131 NodeOutput {
1132 id: "01AAA".into(),
1133 label: "person".into(),
1134 name: "John Doe".into(),
1135 slug: Some("people/id/john-doe--governor-of-test-province".into()),
1136 qualifier: Some("Governor of Test Province".into()),
1137 description: None,
1138 thumbnail: Some("https://files.example.com/thumb.webp".into()),
1139 aliases: vec![],
1140 urls: vec![],
1141 role: vec!["politician".into()],
1142 nationality: Some("ID".into()),
1143 date_of_birth: None,
1144 place_of_birth: None,
1145 status: Some("convicted".into()),
1146 org_type: None,
1147 jurisdiction: None,
1148 headquarters: None,
1149 founded_date: None,
1150 registration_number: None,
1151 event_type: None,
1152 occurred_at: None,
1153 severity: None,
1154 doc_type: None,
1155 issued_at: None,
1156 issuing_authority: None,
1157 case_number: None,
1158 case_type: None,
1159 amounts: vec![],
1160 asset_type: None,
1161 value: None,
1162 tags: vec![],
1163 },
1164 NodeOutput {
1165 id: "01BBB".into(),
1166 label: "organization".into(),
1167 name: "KPK".into(),
1168 slug: Some("organizations/id/kpk--anti-corruption-commission".into()),
1169 qualifier: Some("Anti-Corruption Commission".into()),
1170 description: None,
1171 thumbnail: None,
1172 aliases: vec![],
1173 urls: vec![],
1174 role: vec![],
1175 nationality: None,
1176 date_of_birth: None,
1177 place_of_birth: None,
1178 status: None,
1179 org_type: Some("government_agency".into()),
1180 jurisdiction: Some(Jurisdiction {
1181 country: "ID".into(),
1182 subdivision: None,
1183 }),
1184 headquarters: None,
1185 founded_date: None,
1186 registration_number: None,
1187 event_type: None,
1188 occurred_at: None,
1189 severity: None,
1190 doc_type: None,
1191 issued_at: None,
1192 issuing_authority: None,
1193 case_number: None,
1194 case_type: None,
1195 amounts: vec![],
1196 asset_type: None,
1197 value: None,
1198 tags: vec![],
1199 },
1200 NodeOutput {
1201 id: "01CCC".into(),
1202 label: "event".into(),
1203 name: "Arrest".into(),
1204 slug: None,
1205 qualifier: None,
1206 description: Some("John Doe arrested by KPK.".into()),
1207 thumbnail: None,
1208 aliases: vec![],
1209 urls: vec![],
1210 role: vec![],
1211 nationality: None,
1212 date_of_birth: None,
1213 place_of_birth: None,
1214 status: None,
1215 org_type: None,
1216 jurisdiction: None,
1217 headquarters: None,
1218 founded_date: None,
1219 registration_number: None,
1220 event_type: Some("arrest".into()),
1221 occurred_at: Some("2024-03-15".into()),
1222 severity: None,
1223 doc_type: None,
1224 issued_at: None,
1225 issuing_authority: None,
1226 case_number: None,
1227 case_type: None,
1228 amounts: vec![],
1229 asset_type: None,
1230 value: None,
1231 tags: vec![],
1232 },
1233 ],
1234 relationships: vec![RelOutput {
1235 id: "01DDD".into(),
1236 rel_type: "investigated_by".into(),
1237 source_id: "01BBB".into(),
1238 target_id: "01CCC".into(),
1239 source_urls: vec![],
1240 description: None,
1241 amounts: vec![],
1242 valid_from: None,
1243 valid_until: None,
1244 }],
1245 sources: vec![SourceEntry::Url("https://example.com/article".into())],
1246 }
1247 }
1248
1249 #[test]
1250 fn render_case_produces_valid_html() {
1251 let case = make_case();
1252 let config = HtmlConfig::default();
1253 let html = render_case(&case, &config).unwrap();
1254
1255 assert!(html.starts_with("<article"));
1256 assert!(html.ends_with("</article>\n"));
1257 assert!(html.contains("data-og-title=\"Test Corruption Case\""));
1258 assert!(html.contains("data-og-description="));
1259 assert!(html.contains("<h1 itemprop=\"headline\">Test Corruption Case</h1>"));
1260 assert!(html.contains("loom-tag"));
1261 assert!(html.contains("bribery"));
1262 assert!(html.contains("John Doe"));
1263 assert!(html.contains("KPK"));
1264 assert!(html.contains("Arrest"));
1265 assert!(html.contains("2024-03-15"));
1266 assert!(html.contains("application/ld+json"));
1267 assert!(html.contains("View on canvas"));
1269 assert!(html.contains("/canvas/01TESTCASE0000000000000000"));
1270 }
1271
1272 #[test]
1273 fn render_case_has_sources() {
1274 let case = make_case();
1275 let config = HtmlConfig::default();
1276 let html = render_case(&case, &config).unwrap();
1277 assert!(html.contains("Sources"));
1278 assert!(html.contains("https://example.com/article"));
1279 }
1280
1281 #[test]
1282 fn render_case_entity_cards_link_to_static_views() {
1283 let case = make_case();
1284 let config = HtmlConfig::default();
1285 let html = render_case(&case, &config).unwrap();
1286
1287 assert!(html.contains("href=\"/people/id/john-doe--governor-of-test-province\""));
1289 assert!(html.contains("href=\"/organizations/id/kpk--anti-corruption-commission\""));
1290 assert!(!html.contains("href=\"/canvas/01AAA\""));
1292 assert!(!html.contains("href=\"/canvas/01BBB\""));
1293 }
1294
1295 #[test]
1296 fn render_case_entity_cards_fallback_to_canvas() {
1297 let mut case = make_case();
1298 let config = HtmlConfig::default();
1299 for node in &mut case.nodes {
1301 node.slug = None;
1302 }
1303 let html = render_case(&case, &config).unwrap();
1304
1305 assert!(html.contains("href=\"/canvas/01AAA\""));
1307 assert!(html.contains("href=\"/canvas/01BBB\""));
1308 }
1309
1310 #[test]
1311 fn render_case_omits_connections_table() {
1312 let case = make_case();
1313 let config = HtmlConfig::default();
1314 let html = render_case(&case, &config).unwrap();
1315 assert!(!html.contains("Connections"));
1318 assert!(!html.contains("loom-rel-table"));
1319 }
1320
1321 #[test]
1322 fn render_person_page() {
1323 let case = make_case();
1324 let config = HtmlConfig::default();
1325 let person = &case.nodes[0];
1326 let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
1327 let html = render_person(person, &cases_list, &config).unwrap();
1328
1329 assert!(html.contains("itemtype=\"https://schema.org/Person\""));
1330 assert!(html.contains("John Doe"));
1331 assert!(html.contains("Governor of Test Province"));
1332 assert!(html.contains("/canvas/01AAA"));
1333 assert!(html.contains("Test Corruption Case"));
1334 assert!(html.contains("application/ld+json"));
1335 }
1336
1337 #[test]
1338 fn render_organization_page() {
1339 let case = make_case();
1340 let config = HtmlConfig::default();
1341 let org = &case.nodes[1];
1342 let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
1343 let html = render_organization(org, &cases_list, &config).unwrap();
1344
1345 assert!(html.contains("itemtype=\"https://schema.org/Organization\""));
1346 assert!(html.contains("KPK"));
1347 assert!(html.contains("Indonesia")); }
1349
1350 #[test]
1351 fn render_sitemap_includes_all_urls() {
1352 let cases = vec![("cases/id/corruption/2024/test-case".into(), "Case 1".into())];
1353 let people = vec![("people/id/john-doe".into(), "John".into())];
1354 let orgs = vec![("organizations/id/test-corp".into(), "Corp".into())];
1355 let xml = render_sitemap(&cases, &people, &orgs, "https://redberrythread.org");
1356
1357 assert!(xml.contains("<?xml"));
1358 assert!(xml.contains("/cases/id/corruption/2024/test-case"));
1359 assert!(xml.contains("/people/id/john-doe"));
1360 assert!(xml.contains("/organizations/id/test-corp"));
1361 }
1362
1363 #[test]
1364 fn escape_html_special_chars() {
1365 assert_eq!(escape("<script>"), "<script>");
1366 assert_eq!(escape("AT&T"), "AT&T");
1367 assert_eq!(escape("\"quoted\""), ""quoted"");
1368 }
1369
1370 #[test]
1371 fn truncate_short_string() {
1372 assert_eq!(truncate("hello", 10), "hello");
1373 }
1374
1375 #[test]
1376 fn truncate_long_string() {
1377 let long = "a".repeat(200);
1378 let result = truncate(&long, 120);
1379 assert!(result.len() <= 120);
1380 assert!(result.ends_with("..."));
1381 }
1382
1383 #[test]
1384 fn format_enum_underscore() {
1385 assert_eq!(format_enum("investigated_by"), "Investigated By");
1386 assert_eq!(format_enum("custom:Special Type"), "Special Type");
1387 }
1388
1389 #[test]
1390 fn thumbnail_key_deterministic() {
1391 let k1 = thumbnail_key("https://example.com/photo.jpg");
1392 let k2 = thumbnail_key("https://example.com/photo.jpg");
1393 assert_eq!(k1, k2);
1394 assert!(k1.starts_with("thumbnails/"));
1395 assert!(k1.ends_with(".webp"));
1396 let hex_part = k1
1398 .strip_prefix("thumbnails/")
1399 .and_then(|s| s.strip_suffix(".webp"))
1400 .unwrap_or("");
1401 assert_eq!(hex_part.len(), THUMB_KEY_HEX_LEN);
1402 }
1403
1404 #[test]
1405 fn thumbnail_key_different_urls_differ() {
1406 let k1 = thumbnail_key("https://example.com/a.jpg");
1407 let k2 = thumbnail_key("https://example.com/b.jpg");
1408 assert_ne!(k1, k2);
1409 }
1410
1411 #[test]
1412 fn rewrite_thumbnail_url_no_config() {
1413 let config = HtmlConfig::default();
1414 let result = rewrite_thumbnail_url("https://example.com/photo.jpg", &config);
1415 assert_eq!(result, "https://example.com/photo.jpg");
1416 }
1417
1418 #[test]
1419 fn rewrite_thumbnail_url_with_base() {
1420 let config = HtmlConfig {
1421 thumbnail_base_url: Some("http://files.garage.local:3902/files".into()),
1422 };
1423 let result = rewrite_thumbnail_url("https://example.com/photo.jpg", &config);
1424 assert!(result.starts_with("http://files.garage.local:3902/files/thumbnails/"));
1425 assert!(result.ends_with(".webp"));
1426 assert!(!result.contains("example.com"));
1427 }
1428
1429 #[test]
1430 fn rewrite_thumbnail_url_already_rewritten() {
1431 let config = HtmlConfig {
1432 thumbnail_base_url: Some("https://files.redberrythread.org".into()),
1433 };
1434 let already = "https://files.redberrythread.org/thumbnails/6fc3a49567393053be6138aa346fa97a.webp";
1435 let result = rewrite_thumbnail_url(already, &config);
1436 assert_eq!(result, already, "should not double-hash already-rewritten URLs");
1437 }
1438
1439 #[test]
1440 fn render_case_rewrites_thumbnails() {
1441 let case = make_case();
1442 let config = HtmlConfig {
1443 thumbnail_base_url: Some("http://garage.local/files".into()),
1444 };
1445 let html = render_case(&case, &config).unwrap();
1446
1447 assert!(!html.contains("src=\"https://files.example.com/thumb.webp\""));
1449 assert!(html.contains("src=\"http://garage.local/files/thumbnails/"));
1451 assert!(html.contains("data-og-image=\"http://garage.local/files/thumbnails/"));
1453 }
1454
1455 #[test]
1456 fn render_person_rewrites_thumbnails() {
1457 let case = make_case();
1458 let person = &case.nodes[0];
1459 let config = HtmlConfig {
1460 thumbnail_base_url: Some("http://garage.local/files".into()),
1461 };
1462 let html = render_person(person, &[], &config).unwrap();
1463
1464 assert!(!html.contains("src=\"https://files.example.com/thumb.webp\""));
1465 assert!(html.contains("src=\"http://garage.local/files/thumbnails/"));
1466 }
1467
1468 #[test]
1469 fn render_case_with_related_cases() {
1470 let mut case = make_case();
1471 case.relationships.push(RelOutput {
1473 id: "01RELID".into(),
1474 rel_type: "related_to".into(),
1475 source_id: "01TESTCASE0000000000000000".into(),
1476 target_id: "01TARGETCASE000000000000000".into(),
1477 source_urls: vec![],
1478 description: Some("Connected bribery scandal".into()),
1479 amounts: vec![],
1480 valid_from: None,
1481 valid_until: None,
1482 });
1483 case.nodes.push(NodeOutput {
1484 id: "01TARGETCASE000000000000000".into(),
1485 label: "case".into(),
1486 name: "Target Scandal Case".into(),
1487 slug: Some("cases/id/corruption/2002/target-scandal".into()),
1488 qualifier: None,
1489 description: None,
1490 thumbnail: None,
1491 aliases: vec![],
1492 urls: vec![],
1493 role: vec![],
1494 nationality: None,
1495 date_of_birth: None,
1496 place_of_birth: None,
1497 status: None,
1498 org_type: None,
1499 jurisdiction: None,
1500 headquarters: None,
1501 founded_date: None,
1502 registration_number: None,
1503 event_type: None,
1504 occurred_at: None,
1505 severity: None,
1506 doc_type: None,
1507 issued_at: None,
1508 issuing_authority: None,
1509 case_number: None,
1510 case_type: None,
1511 amounts: vec![],
1512 asset_type: None,
1513 value: None,
1514 tags: vec![],
1515 });
1516
1517 let config = HtmlConfig::default();
1518 let html = render_case(&case, &config).unwrap();
1519
1520 assert!(html.contains("loom-related-cases"));
1521 assert!(html.contains("Related Cases"));
1522 assert!(html.contains("Target Scandal Case"));
1523 assert!(html.contains("loom-related-card"));
1524 assert!(html.contains("Connected bribery scandal"));
1525 }
1526
1527 #[test]
1528 fn render_case_without_related_cases() {
1529 let case = make_case();
1530 let config = HtmlConfig::default();
1531 let html = render_case(&case, &config).unwrap();
1532
1533 assert!(!html.contains("loom-related-cases"));
1534 }
1535}