Skip to main content

weave_content/
html.rs

1//! Static HTML generator for case and entity pages.
2//!
3//! Produces semantic HTML fragments (no `<html>`/`<head>`/`<body>` wrapper)
4//! suitable for embedding in a Phoenix layout. Each fragment includes
5//! `data-og-*` attributes on the root element for meta tag extraction,
6//! Schema.org microdata, and a `<script type="application/ld+json">` block.
7
8#![allow(clippy::format_push_string)]
9
10use crate::domain::Jurisdiction;
11use crate::output::{CaseOutput, NodeOutput, RelOutput};
12use crate::parser::SourceEntry;
13
14/// Maximum size for a single HTML fragment file (500 KB).
15const MAX_FRAGMENT_BYTES: usize = 512_000;
16
17/// Generate a complete case page HTML fragment.
18///
19/// # Errors
20///
21/// Returns an error if the rendered HTML exceeds [`MAX_FRAGMENT_BYTES`].
22pub fn render_case(case: &CaseOutput) -> Result<String, String> {
23    let mut html = String::with_capacity(8192);
24
25    let og_title = truncate(&case.title, 120);
26    let og_description = build_case_og_description(case);
27
28    // Root element with OG data attributes
29    html.push_str(&format!(
30        "<article class=\"loom-case\" itemscope itemtype=\"https://schema.org/Article\" \
31         data-og-title=\"{}\" \
32         data-og-description=\"{}\" \
33         data-og-type=\"article\" \
34         data-og-url=\"/case/{}\">\n",
35        escape_attr(&og_title),
36        escape_attr(&og_description),
37        escape_attr(&case.case_id),
38    ));
39
40    // Header
41    render_case_header(&mut html, case);
42
43    // Sources
44    render_sources(&mut html, &case.sources);
45
46    // People section
47    let people: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "person").collect();
48    if !people.is_empty() {
49        render_entity_section(&mut html, "People", &people);
50    }
51
52    // Organizations section
53    let orgs: Vec<&NodeOutput> = case
54        .nodes
55        .iter()
56        .filter(|n| n.label == "organization")
57        .collect();
58    if !orgs.is_empty() {
59        render_entity_section(&mut html, "Organizations", &orgs);
60    }
61
62    // Timeline section (events sorted by occurred_at)
63    let mut events: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "event").collect();
64    events.sort_by(|a, b| a.occurred_at.cmp(&b.occurred_at));
65    if !events.is_empty() {
66        render_timeline(&mut html, &events);
67    }
68
69    // Connections section
70    if !case.relationships.is_empty() {
71        render_connections(&mut html, &case.relationships, &case.nodes);
72    }
73
74    // JSON-LD
75    render_case_json_ld(&mut html, case);
76
77    html.push_str("</article>\n");
78
79    if html.len() > MAX_FRAGMENT_BYTES {
80        return Err(format!(
81            "HTML fragment exceeds {MAX_FRAGMENT_BYTES} bytes ({} bytes)",
82            html.len()
83        ));
84    }
85
86    Ok(html)
87}
88
89/// Generate a person page HTML fragment.
90///
91/// # Errors
92///
93/// Returns an error if the rendered HTML exceeds [`MAX_FRAGMENT_BYTES`].
94pub fn render_person(
95    node: &NodeOutput,
96    cases: &[(String, String)], // (case_id, case_title)
97) -> Result<String, String> {
98    let mut html = String::with_capacity(4096);
99
100    let og_title = truncate(&node.name, 120);
101    let og_description = build_person_og_description(node);
102
103    html.push_str(&format!(
104        "<article class=\"loom-person\" itemscope itemtype=\"https://schema.org/Person\" \
105         data-og-title=\"{}\" \
106         data-og-description=\"{}\" \
107         data-og-type=\"profile\" \
108         data-og-url=\"/person/{}\">\n",
109        escape_attr(&og_title),
110        escape_attr(&og_description),
111        escape_attr(&node.id),
112    ));
113
114    render_entity_detail(&mut html, node);
115    render_cases_list(&mut html, cases);
116    render_person_json_ld(&mut html, node);
117
118    html.push_str("</article>\n");
119
120    check_size(&html)
121}
122
123/// Generate an organization page HTML fragment.
124///
125/// # Errors
126///
127/// Returns an error if the rendered HTML exceeds [`MAX_FRAGMENT_BYTES`].
128pub fn render_organization(
129    node: &NodeOutput,
130    cases: &[(String, String)],
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_org_og_description(node);
136
137    html.push_str(&format!(
138        "<article class=\"loom-organization\" itemscope itemtype=\"https://schema.org/Organization\" \
139         data-og-title=\"{}\" \
140         data-og-description=\"{}\" \
141         data-og-type=\"profile\" \
142         data-og-url=\"/organization/{}\">\n",
143        escape_attr(&og_title),
144        escape_attr(&og_description),
145        escape_attr(&node.id),
146    ));
147
148    render_entity_detail(&mut html, node);
149    render_cases_list(&mut html, cases);
150    render_org_json_ld(&mut html, node);
151
152    html.push_str("</article>\n");
153
154    check_size(&html)
155}
156
157// --- Case sections ---
158
159fn render_case_header(html: &mut String, case: &CaseOutput) {
160    html.push_str(&format!(
161        "  <header class=\"loom-case-header\">\n    <h1 itemprop=\"headline\">{}</h1>\n",
162        escape(&case.title)
163    ));
164
165    if !case.tags.is_empty() {
166        html.push_str("    <div class=\"loom-tags\">\n");
167        for tag in &case.tags {
168            html.push_str(&format!(
169                "      <a href=\"/tags/{}\" class=\"loom-tag\">{}</a>\n",
170                escape_attr(tag),
171                escape(tag)
172            ));
173        }
174        html.push_str("    </div>\n");
175    }
176
177    if !case.summary.is_empty() {
178        html.push_str(&format!(
179            "    <p class=\"loom-summary\" itemprop=\"description\">{}</p>\n",
180            escape(&case.summary)
181        ));
182    }
183
184    html.push_str("  </header>\n");
185}
186
187fn render_sources(html: &mut String, sources: &[SourceEntry]) {
188    if sources.is_empty() {
189        return;
190    }
191    html.push_str("  <section class=\"loom-sources\">\n    <h2>Sources</h2>\n    <ol>\n");
192    for source in sources {
193        match source {
194            SourceEntry::Url(url) => {
195                html.push_str(&format!(
196                    "      <li><a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a></li>\n",
197                    escape_attr(url),
198                    escape(url)
199                ));
200            }
201            SourceEntry::Structured { url, title, .. } => {
202                let display = title.as_deref().unwrap_or(url.as_str());
203                html.push_str(&format!(
204                    "      <li><a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a></li>\n",
205                    escape_attr(url),
206                    escape(display)
207                ));
208            }
209        }
210    }
211    html.push_str("    </ol>\n  </section>\n");
212}
213
214fn render_entity_section(html: &mut String, title: &str, nodes: &[&NodeOutput]) {
215    html.push_str(&format!(
216        "  <section class=\"loom-entities loom-entities-{}\">\n    <h2>{title}</h2>\n    <div class=\"loom-entity-cards\">\n",
217        title.to_lowercase()
218    ));
219    for node in nodes {
220        render_entity_card(html, node);
221    }
222    html.push_str("    </div>\n  </section>\n");
223}
224
225fn render_entity_card(html: &mut String, node: &NodeOutput) {
226    let schema_type = match node.label.as_str() {
227        "person" => "Person",
228        "organization" => "Organization",
229        _ => "Thing",
230    };
231    html.push_str(&format!(
232        "      <div class=\"loom-entity-card\" itemscope itemtype=\"https://schema.org/{schema_type}\">\n"
233    ));
234
235    if let Some(thumb) = &node.thumbnail {
236        html.push_str(&format!(
237            "        <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail\" itemprop=\"image\" loading=\"lazy\" width=\"64\" height=\"64\" />\n",
238            escape_attr(thumb),
239            escape_attr(&node.name)
240        ));
241    }
242
243    html.push_str(&format!(
244        "        <div class=\"loom-entity-info\">\n          \
245         <a href=\"/canvas/{}\" class=\"loom-entity-name\" itemprop=\"name\">{}</a>\n",
246        escape_attr(&node.id),
247        escape(&node.name)
248    ));
249
250    if let Some(q) = &node.qualifier {
251        html.push_str(&format!(
252            "          <span class=\"loom-qualifier\">{}</span>\n",
253            escape(q)
254        ));
255    }
256
257    // Label-specific fields
258    match node.label.as_str() {
259        "person" => {
260            render_dl_field(html, "Role", &node.role.join(", "));
261            render_dl_opt(html, "Nationality", node.nationality.as_ref());
262        }
263        "organization" => {
264            render_dl_opt(html, "Type", node.org_type.as_ref());
265            if let Some(j) = &node.jurisdiction {
266                render_dl_field(html, "Jurisdiction", &format_jurisdiction(j));
267            }
268        }
269        _ => {}
270    }
271
272    html.push_str("        </div>\n      </div>\n");
273}
274
275fn render_timeline(html: &mut String, events: &[&NodeOutput]) {
276    html.push_str(
277        "  <section class=\"loom-timeline\">\n    <h2>Timeline</h2>\n    <ol class=\"loom-events\">\n",
278    );
279    for event in events {
280        html.push_str("      <li class=\"loom-event\">\n");
281        if let Some(date) = &event.occurred_at {
282            html.push_str(&format!(
283                "        <time datetime=\"{}\" class=\"loom-event-date\">{}</time>\n",
284                escape_attr(date),
285                escape(date)
286            ));
287        }
288        html.push_str(&format!(
289            "        <span class=\"loom-event-name\">{}</span>\n",
290            escape(&event.name)
291        ));
292        if let Some(et) = &event.event_type {
293            html.push_str(&format!(
294                "        <span class=\"loom-event-type\">{}</span>\n",
295                escape(&format_enum(et))
296            ));
297        }
298        if let Some(desc) = &event.description {
299            html.push_str(&format!(
300                "        <p class=\"loom-event-description\">{}</p>\n",
301                escape(desc)
302            ));
303        }
304        html.push_str("      </li>\n");
305    }
306    html.push_str("    </ol>\n  </section>\n");
307}
308
309fn render_connections(html: &mut String, rels: &[RelOutput], nodes: &[NodeOutput]) {
310    html.push_str(
311        "  <section class=\"loom-connections\">\n    <h2>Connections</h2>\n    \
312         <table class=\"loom-rel-table\">\n      <thead>\n        \
313         <tr><th>From</th><th>Type</th><th>To</th><th>Details</th></tr>\n      \
314         </thead>\n      <tbody>\n",
315    );
316    for rel in rels {
317        let source_name = nodes
318            .iter()
319            .find(|n| n.id == rel.source_id)
320            .map_or("?", |n| &n.name);
321        let target_name = nodes
322            .iter()
323            .find(|n| n.id == rel.target_id)
324            .map_or("?", |n| &n.name);
325
326        let mut details = Vec::new();
327        if let Some(desc) = &rel.description {
328            details.push(desc.clone());
329        }
330        if let Some(amt) = &rel.amount {
331            if let Some(cur) = &rel.currency {
332                details.push(format!("{amt} {cur}"));
333            } else {
334                details.push(amt.clone());
335            }
336        }
337        if let Some(vf) = &rel.valid_from {
338            details.push(format!("from {vf}"));
339        }
340        if let Some(vu) = &rel.valid_until {
341            details.push(format!("until {vu}"));
342        }
343
344        html.push_str(&format!(
345            "        <tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
346            escape(source_name),
347            escape(&format_enum(&rel.rel_type)),
348            escape(target_name),
349            escape(&details.join("; ")),
350        ));
351    }
352    html.push_str("      </tbody>\n    </table>\n  </section>\n");
353}
354
355// --- Entity detail page ---
356
357fn render_entity_detail(html: &mut String, node: &NodeOutput) {
358    html.push_str("  <header class=\"loom-entity-header\">\n");
359
360    if let Some(thumb) = &node.thumbnail {
361        html.push_str(&format!(
362            "    <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail-large\" itemprop=\"image\" loading=\"lazy\" width=\"128\" height=\"128\" />\n",
363            escape_attr(thumb),
364            escape_attr(&node.name)
365        ));
366    }
367
368    html.push_str(&format!(
369        "    <h1 itemprop=\"name\">{}</h1>\n",
370        escape(&node.name)
371    ));
372
373    if let Some(q) = &node.qualifier {
374        html.push_str(&format!(
375            "    <p class=\"loom-qualifier\">{}</p>\n",
376            escape(q)
377        ));
378    }
379
380    html.push_str(&format!(
381        "    <a href=\"/canvas/{}\" class=\"loom-canvas-link\">View on canvas</a>\n",
382        escape_attr(&node.id)
383    ));
384    html.push_str("  </header>\n");
385
386    // Description
387    if let Some(desc) = &node.description {
388        html.push_str(&format!(
389            "  <p class=\"loom-description\" itemprop=\"description\">{}</p>\n",
390            escape(desc)
391        ));
392    }
393
394    // Fields as definition list
395    html.push_str("  <dl class=\"loom-fields\">\n");
396
397    match node.label.as_str() {
398        "person" => {
399            render_dl_item(html, "Role", &node.role.join(", "));
400            render_dl_opt_item(html, "Nationality", node.nationality.as_ref());
401            render_dl_opt_item(html, "Date of Birth", node.date_of_birth.as_ref());
402            render_dl_opt_item(html, "Place of Birth", node.place_of_birth.as_ref());
403            render_dl_opt_item(html, "Status", node.status.as_ref());
404        }
405        "organization" => {
406            render_dl_opt_item(html, "Type", node.org_type.as_ref());
407            if let Some(j) = &node.jurisdiction {
408                render_dl_item(html, "Jurisdiction", &format_jurisdiction(j));
409            }
410            render_dl_opt_item(html, "Headquarters", node.headquarters.as_ref());
411            render_dl_opt_item(html, "Founded", node.founded_date.as_ref());
412            render_dl_opt_item(html, "Registration", node.registration_number.as_ref());
413            render_dl_opt_item(html, "Status", node.status.as_ref());
414        }
415        _ => {}
416    }
417
418    html.push_str("  </dl>\n");
419
420    // Aliases
421    if !node.aliases.is_empty() {
422        html.push_str("  <div class=\"loom-aliases\">\n    <h3>Also known as</h3>\n    <ul>\n");
423        for alias in &node.aliases {
424            html.push_str(&format!("      <li>{}</li>\n", escape(alias)));
425        }
426        html.push_str("    </ul>\n  </div>\n");
427    }
428}
429
430fn render_cases_list(html: &mut String, cases: &[(String, String)]) {
431    if cases.is_empty() {
432        return;
433    }
434    html.push_str(
435        "  <section class=\"loom-cases\">\n    <h2>Cases</h2>\n    <ul class=\"loom-case-list\">\n",
436    );
437    for (case_id, case_title) in cases {
438        html.push_str(&format!(
439            "      <li><a href=\"/case/{}\">{}</a></li>\n",
440            escape_attr(case_id),
441            escape(case_title)
442        ));
443    }
444    html.push_str("    </ul>\n  </section>\n");
445}
446
447// --- JSON-LD ---
448
449fn render_case_json_ld(html: &mut String, case: &CaseOutput) {
450    let mut ld = serde_json::json!({
451        "@context": "https://schema.org",
452        "@type": "Article",
453        "headline": truncate(&case.title, 120),
454        "description": truncate(&case.summary, 200),
455        "url": format!("/case/{}", case.case_id),
456    });
457
458    if !case.sources.is_empty() {
459        let urls: Vec<&str> = case
460            .sources
461            .iter()
462            .map(|s| match s {
463                SourceEntry::Url(u) => u.as_str(),
464                SourceEntry::Structured { url, .. } => url.as_str(),
465            })
466            .collect();
467        ld["citation"] = serde_json::json!(urls);
468    }
469
470    html.push_str(&format!(
471        "  <script type=\"application/ld+json\">{}</script>\n",
472        serde_json::to_string(&ld).unwrap_or_default()
473    ));
474}
475
476fn render_person_json_ld(html: &mut String, node: &NodeOutput) {
477    let mut ld = serde_json::json!({
478        "@context": "https://schema.org",
479        "@type": "Person",
480        "name": &node.name,
481        "url": format!("/person/{}", node.id),
482    });
483
484    if let Some(nat) = &node.nationality {
485        ld["nationality"] = serde_json::json!(nat);
486    }
487    if let Some(desc) = &node.description {
488        ld["description"] = serde_json::json!(truncate(desc, 200));
489    }
490    if let Some(thumb) = &node.thumbnail {
491        ld["image"] = serde_json::json!(thumb);
492    }
493
494    html.push_str(&format!(
495        "  <script type=\"application/ld+json\">{}</script>\n",
496        serde_json::to_string(&ld).unwrap_or_default()
497    ));
498}
499
500fn render_org_json_ld(html: &mut String, node: &NodeOutput) {
501    let mut ld = serde_json::json!({
502        "@context": "https://schema.org",
503        "@type": "Organization",
504        "name": &node.name,
505        "url": format!("/organization/{}", node.id),
506    });
507
508    if let Some(desc) = &node.description {
509        ld["description"] = serde_json::json!(truncate(desc, 200));
510    }
511    if let Some(thumb) = &node.thumbnail {
512        ld["logo"] = serde_json::json!(thumb);
513    }
514
515    html.push_str(&format!(
516        "  <script type=\"application/ld+json\">{}</script>\n",
517        serde_json::to_string(&ld).unwrap_or_default()
518    ));
519}
520
521// --- Sitemap ---
522
523/// Generate a sitemap XML string.
524pub fn render_sitemap(
525    cases: &[(String, String)],         // (case_id, title)
526    people: &[(String, String)],        // (nulid, name)
527    organizations: &[(String, String)], // (nulid, name)
528    base_url: &str,
529) -> String {
530    let mut xml = String::with_capacity(4096);
531    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
532    xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
533
534    for (case_id, _) in cases {
535        xml.push_str(&format!(
536            "  <url><loc>{base_url}/case/{}</loc></url>\n",
537            escape(case_id)
538        ));
539    }
540    for (id, _) in people {
541        xml.push_str(&format!(
542            "  <url><loc>{base_url}/person/{}</loc></url>\n",
543            escape(id)
544        ));
545    }
546    for (id, _) in organizations {
547        xml.push_str(&format!(
548            "  <url><loc>{base_url}/organization/{}</loc></url>\n",
549            escape(id)
550        ));
551    }
552
553    xml.push_str("</urlset>\n");
554    xml
555}
556
557// --- Helpers ---
558
559fn build_case_og_description(case: &CaseOutput) -> String {
560    if !case.summary.is_empty() {
561        return truncate(&case.summary, 200);
562    }
563    let people_count = case.nodes.iter().filter(|n| n.label == "person").count();
564    let org_count = case
565        .nodes
566        .iter()
567        .filter(|n| n.label == "organization")
568        .count();
569    truncate(
570        &format!(
571            "{} people, {} organizations, {} connections",
572            people_count,
573            org_count,
574            case.relationships.len()
575        ),
576        200,
577    )
578}
579
580fn build_person_og_description(node: &NodeOutput) -> String {
581    let mut parts = Vec::new();
582    if let Some(q) = &node.qualifier {
583        parts.push(q.clone());
584    }
585    if !node.role.is_empty() {
586        parts.push(node.role.join(", "));
587    }
588    if let Some(nat) = &node.nationality {
589        parts.push(nat.clone());
590    }
591    if parts.is_empty() {
592        return truncate(&node.name, 200);
593    }
594    truncate(&format!("{} — {}", node.name, parts.join(" · ")), 200)
595}
596
597fn build_org_og_description(node: &NodeOutput) -> String {
598    let mut parts = Vec::new();
599    if let Some(q) = &node.qualifier {
600        parts.push(q.clone());
601    }
602    if let Some(ot) = &node.org_type {
603        parts.push(format_enum(ot));
604    }
605    if let Some(j) = &node.jurisdiction {
606        parts.push(format_jurisdiction(j));
607    }
608    if parts.is_empty() {
609        return truncate(&node.name, 200);
610    }
611    truncate(&format!("{} — {}", node.name, parts.join(" · ")), 200)
612}
613
614fn check_size(html: &str) -> Result<String, String> {
615    if html.len() > MAX_FRAGMENT_BYTES {
616        Err(format!(
617            "HTML fragment exceeds {MAX_FRAGMENT_BYTES} bytes ({} bytes)",
618            html.len()
619        ))
620    } else {
621        Ok(html.to_string())
622    }
623}
624
625fn truncate(s: &str, max: usize) -> String {
626    if s.len() <= max {
627        s.to_string()
628    } else {
629        let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
630        format!("{truncated}...")
631    }
632}
633
634fn escape(s: &str) -> String {
635    s.replace('&', "&amp;")
636        .replace('<', "&lt;")
637        .replace('>', "&gt;")
638        .replace('"', "&quot;")
639}
640
641fn escape_attr(s: &str) -> String {
642    escape(s)
643}
644
645fn format_jurisdiction(j: &Jurisdiction) -> String {
646    match &j.subdivision {
647        Some(sub) => format!("{}, {sub}", j.country),
648        None => j.country.clone(),
649    }
650}
651
652fn format_enum(s: &str) -> String {
653    if let Some(custom) = s.strip_prefix("custom:") {
654        return custom.to_string();
655    }
656    s.replace('_', " ")
657}
658
659fn render_dl_field(html: &mut String, label: &str, value: &str) {
660    if !value.is_empty() {
661        html.push_str(&format!(
662            "          <span class=\"loom-field\"><strong>{label}:</strong> {}</span>\n",
663            escape(value)
664        ));
665    }
666}
667
668fn render_dl_opt(html: &mut String, label: &str, value: Option<&String>) {
669    if let Some(v) = value {
670        render_dl_field(html, label, v);
671    }
672}
673
674fn render_dl_item(html: &mut String, label: &str, value: &str) {
675    if !value.is_empty() {
676        html.push_str(&format!(
677            "    <dt>{label}</dt>\n    <dd>{}</dd>\n",
678            escape(value)
679        ));
680    }
681}
682
683fn render_dl_opt_item(html: &mut String, label: &str, value: Option<&String>) {
684    if let Some(v) = value {
685        render_dl_item(html, label, v);
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::output::{CaseOutput, NodeOutput, RelOutput};
693    use crate::parser::SourceEntry;
694
695    fn make_case() -> CaseOutput {
696        CaseOutput {
697            case_id: "test-case".into(),
698            title: "Test Corruption Case".into(),
699            summary: "A politician was caught accepting bribes.".into(),
700            tags: vec!["bribery".into(), "government".into()],
701            nodes: vec![
702                NodeOutput {
703                    id: "01AAA".into(),
704                    label: "person".into(),
705                    name: "John Doe".into(),
706                    qualifier: Some("Governor of Test Province".into()),
707                    description: None,
708                    thumbnail: Some("https://files.example.com/thumb.webp".into()),
709                    aliases: vec![],
710                    urls: vec![],
711                    role: vec!["politician".into()],
712                    nationality: Some("ID".into()),
713                    date_of_birth: None,
714                    place_of_birth: None,
715                    status: Some("convicted".into()),
716                    org_type: None,
717                    jurisdiction: None,
718                    headquarters: None,
719                    founded_date: None,
720                    registration_number: None,
721                    event_type: None,
722                    occurred_at: None,
723                    severity: None,
724                    doc_type: None,
725                    issued_at: None,
726                    issuing_authority: None,
727                    case_number: None,
728                    asset_type: None,
729                    value: None,
730                    tags: vec![],
731                },
732                NodeOutput {
733                    id: "01BBB".into(),
734                    label: "organization".into(),
735                    name: "KPK".into(),
736                    qualifier: Some("Anti-Corruption Commission".into()),
737                    description: None,
738                    thumbnail: None,
739                    aliases: vec![],
740                    urls: vec![],
741                    role: vec![],
742                    nationality: None,
743                    date_of_birth: None,
744                    place_of_birth: None,
745                    status: None,
746                    org_type: Some("government_agency".into()),
747                    jurisdiction: Some(Jurisdiction {
748                        country: "ID".into(),
749                        subdivision: None,
750                    }),
751                    headquarters: None,
752                    founded_date: None,
753                    registration_number: None,
754                    event_type: None,
755                    occurred_at: None,
756                    severity: None,
757                    doc_type: None,
758                    issued_at: None,
759                    issuing_authority: None,
760                    case_number: None,
761                    asset_type: None,
762                    value: None,
763                    tags: vec![],
764                },
765                NodeOutput {
766                    id: "01CCC".into(),
767                    label: "event".into(),
768                    name: "Arrest".into(),
769                    qualifier: None,
770                    description: Some("John Doe arrested by KPK.".into()),
771                    thumbnail: None,
772                    aliases: vec![],
773                    urls: vec![],
774                    role: vec![],
775                    nationality: None,
776                    date_of_birth: None,
777                    place_of_birth: None,
778                    status: None,
779                    org_type: None,
780                    jurisdiction: None,
781                    headquarters: None,
782                    founded_date: None,
783                    registration_number: None,
784                    event_type: Some("arrest".into()),
785                    occurred_at: Some("2024-03-15".into()),
786                    severity: None,
787                    doc_type: None,
788                    issued_at: None,
789                    issuing_authority: None,
790                    case_number: None,
791                    asset_type: None,
792                    value: None,
793                    tags: vec![],
794                },
795            ],
796            relationships: vec![RelOutput {
797                id: "01DDD".into(),
798                rel_type: "investigated_by".into(),
799                source_id: "01BBB".into(),
800                target_id: "01CCC".into(),
801                source_urls: vec![],
802                description: None,
803                amount: None,
804                currency: None,
805                valid_from: None,
806                valid_until: None,
807            }],
808            sources: vec![SourceEntry::Url("https://example.com/article".into())],
809        }
810    }
811
812    #[test]
813    fn render_case_produces_valid_html() {
814        let case = make_case();
815        let html = render_case(&case).unwrap();
816
817        assert!(html.starts_with("<article"));
818        assert!(html.ends_with("</article>\n"));
819        assert!(html.contains("data-og-title=\"Test Corruption Case\""));
820        assert!(html.contains("data-og-description="));
821        assert!(html.contains("<h1 itemprop=\"headline\">Test Corruption Case</h1>"));
822        assert!(html.contains("loom-tag"));
823        assert!(html.contains("bribery"));
824        assert!(html.contains("John Doe"));
825        assert!(html.contains("KPK"));
826        assert!(html.contains("Arrest"));
827        assert!(html.contains("2024-03-15"));
828        assert!(html.contains("application/ld+json"));
829    }
830
831    #[test]
832    fn render_case_has_sources() {
833        let case = make_case();
834        let html = render_case(&case).unwrap();
835        assert!(html.contains("Sources"));
836        assert!(html.contains("https://example.com/article"));
837    }
838
839    #[test]
840    fn render_case_has_connections_table() {
841        let case = make_case();
842        let html = render_case(&case).unwrap();
843        assert!(html.contains("Connections"));
844        assert!(html.contains("investigated by"));
845        assert!(html.contains("<table"));
846    }
847
848    #[test]
849    fn render_person_page() {
850        let case = make_case();
851        let person = &case.nodes[0];
852        let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
853        let html = render_person(person, &cases_list).unwrap();
854
855        assert!(html.contains("itemtype=\"https://schema.org/Person\""));
856        assert!(html.contains("John Doe"));
857        assert!(html.contains("Governor of Test Province"));
858        assert!(html.contains("/canvas/01AAA"));
859        assert!(html.contains("Test Corruption Case"));
860        assert!(html.contains("application/ld+json"));
861    }
862
863    #[test]
864    fn render_organization_page() {
865        let case = make_case();
866        let org = &case.nodes[1];
867        let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
868        let html = render_organization(org, &cases_list).unwrap();
869
870        assert!(html.contains("itemtype=\"https://schema.org/Organization\""));
871        assert!(html.contains("KPK"));
872        assert!(html.contains("ID")); // jurisdiction
873    }
874
875    #[test]
876    fn render_sitemap_includes_all_urls() {
877        let cases = vec![("case-1".into(), "Case 1".into())];
878        let people = vec![("01AAA".into(), "John".into())];
879        let orgs = vec![("01BBB".into(), "Corp".into())];
880        let xml = render_sitemap(&cases, &people, &orgs, "https://redberrythread.org");
881
882        assert!(xml.contains("<?xml"));
883        assert!(xml.contains("/case/case-1"));
884        assert!(xml.contains("/person/01AAA"));
885        assert!(xml.contains("/organization/01BBB"));
886    }
887
888    #[test]
889    fn escape_html_special_chars() {
890        assert_eq!(escape("<script>"), "&lt;script&gt;");
891        assert_eq!(escape("AT&T"), "AT&amp;T");
892        assert_eq!(escape("\"quoted\""), "&quot;quoted&quot;");
893    }
894
895    #[test]
896    fn truncate_short_string() {
897        assert_eq!(truncate("hello", 10), "hello");
898    }
899
900    #[test]
901    fn truncate_long_string() {
902        let long = "a".repeat(200);
903        let result = truncate(&long, 120);
904        assert!(result.len() <= 120);
905        assert!(result.ends_with("..."));
906    }
907
908    #[test]
909    fn format_enum_underscore() {
910        assert_eq!(format_enum("investigated_by"), "investigated by");
911        assert_eq!(format_enum("custom:Special Type"), "Special Type");
912    }
913}