Skip to main content

weave_content/
html.rs

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