typstify_ui/
article.rs

1//! Article component for rendering page content.
2//!
3//! Provides safe HTML rendering with custom CSS/JS injection.
4
5use leptos::prelude::*;
6
7/// Article component properties.
8#[derive(Clone, Debug, Default, PartialEq)]
9pub struct ArticleData {
10    /// The HTML content to render.
11    pub content: String,
12
13    /// Page title.
14    pub title: String,
15
16    /// Custom CSS files to include.
17    pub custom_css: Vec<String>,
18
19    /// Custom JS files to include.
20    pub custom_js: Vec<String>,
21}
22
23/// Article component for rendering HTML content.
24///
25/// Renders pre-rendered HTML content with optional custom CSS and JS.
26#[component]
27pub fn Article(
28    /// The article data to render.
29    data: Signal<ArticleData>,
30) -> impl IntoView {
31    view! {
32      <article class="typstify-article">
33        // Custom CSS links
34        <For
35          each=move || data.get().custom_css.clone()
36          key=|css| css.clone()
37          children=move |css| {
38            view! { <link rel="stylesheet" href=css /> }
39          }
40        />
41
42        // Article header
43        <header class="typstify-article-header">
44          <h1 class="typstify-article-title">{move || data.get().title.clone()}</h1>
45        </header>
46
47        // Article content (rendered HTML)
48        <div class="typstify-article-content" inner_html=move || data.get().content.clone()></div>
49
50        // Custom JS scripts (deferred)
51        <For
52          each=move || data.get().custom_js.clone()
53          key=|js| js.clone()
54          children=move |js| {
55            view! { <script src=js defer=true></script> }
56          }
57        />
58
59      </article>
60    }
61}
62
63/// Article metadata component.
64#[component]
65pub fn ArticleMeta(
66    /// Publication date.
67    #[prop(optional)]
68    date: Option<String>,
69    /// Reading time in minutes.
70    #[prop(optional)]
71    reading_time: Option<u32>,
72    /// Tags.
73    #[prop(default = vec![])]
74    tags: Vec<String>,
75) -> impl IntoView {
76    let has_date = date.is_some();
77    let date_value = date.clone();
78    let has_reading_time = reading_time.is_some();
79    let reading_time_value = reading_time.unwrap_or(0);
80    let has_tags = !tags.is_empty();
81    let tags_list = StoredValue::new(tags);
82
83    view! {
84      <div class="typstify-article-meta">
85        <Show when=move || has_date>
86          <time class="typstify-article-date">{date_value.clone()}</time>
87        </Show>
88
89        <Show when=move || has_reading_time>
90          <span class="typstify-article-reading-time">{reading_time_value} " min read"</span>
91        </Show>
92
93        <Show when=move || has_tags>
94          <div class="typstify-article-tags">
95            <For
96              each=move || tags_list.get_value()
97              key=|tag| tag.clone()
98              children=move |tag| {
99                let href = format!("/tags/{}", tag.to_lowercase());
100                view! {
101                  <a href=href class="typstify-tag">
102                    {tag}
103                  </a>
104                }
105              }
106            />
107
108          </div>
109        </Show>
110      </div>
111    }
112}
113
114/// Prose wrapper for styled article content.
115#[component]
116pub fn Prose(
117    /// Children content.
118    children: Children,
119) -> impl IntoView {
120    view! { <div class="typstify-prose">{children()}</div> }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_article_data_default() {
129        let data = ArticleData::default();
130        assert!(data.content.is_empty());
131        assert!(data.title.is_empty());
132        assert!(data.custom_css.is_empty());
133        assert!(data.custom_js.is_empty());
134    }
135
136    #[test]
137    fn test_article_data_creation() {
138        let data = ArticleData {
139            content: "<p>Hello</p>".to_string(),
140            title: "Test Article".to_string(),
141            custom_css: vec!["style.css".to_string()],
142            custom_js: vec!["script.js".to_string()],
143        };
144
145        assert_eq!(data.title, "Test Article");
146        assert_eq!(data.custom_css.len(), 1);
147        assert_eq!(data.custom_js.len(), 1);
148    }
149}