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