Skip to main content

systemprompt_content/
list_items_renderer.rs

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