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