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