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