Skip to main content

systemprompt_content/
list_items_renderer.rs

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