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