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