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