systemprompt_content/
list_items_renderer.rs1use std::sync::Arc;
2
3use anyhow::Result;
4use async_trait::async_trait;
5use serde_json::Value;
6use systemprompt_provider_contracts::{ComponentContext, ComponentRenderer, RenderedComponent};
7
8const PLACEHOLDER_IMAGE_SVG: &str = r#"<div class="card-image card-image--placeholder">
9 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
10 <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
11 <circle cx="8.5" cy="8.5" r="1.5"/>
12 <polyline points="21 15 16 10 5 21"/>
13 </svg>
14 </div>"#;
15
16#[derive(Debug, Clone, Copy, Default)]
17pub struct ListItemsCardRenderer;
18
19#[async_trait]
20impl ComponentRenderer for ListItemsCardRenderer {
21 fn component_id(&self) -> &'static str {
22 "list-items-cards"
23 }
24
25 fn variable_name(&self) -> &'static str {
26 "ITEMS"
27 }
28
29 fn applies_to(&self) -> Vec<String> {
30 vec!["blog-list".into(), "news-list".into(), "pages-list".into()]
31 }
32
33 async fn render(&self, ctx: &ComponentContext<'_>) -> Result<RenderedComponent> {
34 let items = ctx.all_items.unwrap_or(&[]);
35 let url_prefix = extract_url_prefix(ctx);
36
37 let cards_html: Vec<String> = items
38 .iter()
39 .filter_map(|item| render_card_html(item, &url_prefix))
40 .collect();
41
42 Ok(RenderedComponent::new(
43 self.variable_name(),
44 cards_html.join("\n"),
45 ))
46 }
47
48 fn priority(&self) -> u32 {
49 100
50 }
51}
52
53fn extract_url_prefix(ctx: &ComponentContext<'_>) -> String {
54 ctx.all_items
55 .and_then(|items| items.first())
56 .and_then(|item| item.get("content_type"))
57 .and_then(Value::as_str)
58 .map_or_else(String::new, |ct| {
59 format!("/{}", ct.strip_suffix("-list").unwrap_or(ct))
60 })
61}
62
63fn render_card_html(item: &Value, url_prefix: &str) -> Option<String> {
64 let title = item.get("title")?.as_str()?;
65 let slug = item.get("slug")?.as_str()?;
66 let description = item
67 .get("description")
68 .and_then(Value::as_str)
69 .unwrap_or("");
70 let image = item.get("image").and_then(Value::as_str);
71 let date = format_published_date(item);
72
73 let image_html = render_image_html(image, title);
74
75 Some(format!(
76 r#"<a href="{url_prefix}/{slug}" class="content-card-link">
77 <article class="content-card">
78 {image_html}
79 <div class="card-content">
80 <h2 class="card-title">{title}</h2>
81 <p class="card-excerpt">{description}</p>
82 <div class="card-meta">
83 <time class="card-date">{date}</time>
84 </div>
85 </div>
86 </article>
87</a>"#
88 ))
89}
90
91fn format_published_date(item: &Value) -> String {
92 item.get("published_at")
93 .and_then(Value::as_str)
94 .and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok())
95 .map_or_else(String::new, |dt| dt.format("%B %d, %Y").to_string())
96}
97
98fn render_image_html(image: Option<&str>, alt: &str) -> String {
99 image.filter(|s| !s.is_empty()).map_or_else(
100 || PLACEHOLDER_IMAGE_SVG.to_string(),
101 |img| {
102 format!(
103 r#"<div class="card-image">
104 <img src="{img}" alt="{alt}" loading="lazy" />
105 </div>"#
106 )
107 },
108 )
109}
110
111pub fn default_list_items_renderer() -> Arc<dyn ComponentRenderer> {
112 Arc::new(ListItemsCardRenderer)
113}