rs_web/content/
post.rs

1use anyhow::{Context, Result};
2use std::path::{Path, PathBuf};
3
4use super::frontmatter::{Frontmatter, parse_frontmatter};
5use crate::config::Config;
6use crate::encryption::EncryptedContent;
7
8/// Content type of the post source file
9#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
10pub enum ContentType {
11    /// Markdown file (.md) - processed through markdown pipeline
12    Markdown,
13    /// HTML file (.html) - processed through Tera templating
14    Html,
15}
16
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct Post {
19    /// Slug derived from filename (with date prefix stripped)
20    pub file_slug: String,
21    pub section: String,
22    pub frontmatter: Frontmatter,
23    pub content: String,
24    pub html: String,
25    pub reading_time: u32,
26    pub word_count: usize,
27    /// Encrypted content data (set when frontmatter.encrypted is true)
28    pub encrypted_content: Option<EncryptedContent>,
29    /// Whether this post has :::encrypted blocks (partial encryption)
30    pub has_encrypted_blocks: bool,
31    /// Source file type (Markdown or HTML)
32    pub content_type: ContentType,
33    /// Path to the source file
34    pub source_path: PathBuf,
35    /// Path to the source directory (for directory-based posts)
36    /// This is set when iterate = "directories" and allows templates to access
37    /// the full directory path for loading additional files via Tera functions
38    pub source_dir: Option<PathBuf>,
39}
40
41impl Post {
42    pub fn from_file_with_section<P: AsRef<Path>>(path: P, section: &str) -> Result<Self> {
43        let path = path.as_ref();
44        let raw_content = std::fs::read_to_string(path)
45            .with_context(|| format!("Failed to read post: {:?}", path))?;
46
47        let (frontmatter, content) = parse_frontmatter(&raw_content)?;
48
49        // Determine content type from file extension
50        let content_type = match path.extension().and_then(|e| e.to_str()) {
51            Some("html") | Some("htm") => ContentType::Html,
52            _ => ContentType::Markdown,
53        };
54
55        // Extract slug from filename (e.g., "2024-01-15-my-post.md" -> "my-post")
56        let file_slug = path
57            .file_stem()
58            .and_then(|s| s.to_str())
59            .map(|s| {
60                // Remove date prefix if present (YYYY-MM-DD-)
61                if s.len() > 11 && s.chars().nth(4) == Some('-') && s.chars().nth(7) == Some('-') {
62                    s[11..].to_string()
63                } else {
64                    s.to_string()
65                }
66            })
67            .unwrap_or_else(|| "untitled".to_string());
68
69        let word_count = content.split_whitespace().count();
70        let reading_time = (word_count / 200).max(1) as u32; // ~200 words per minute
71
72        Ok(Self {
73            file_slug,
74            section: section.to_string(),
75            frontmatter,
76            content: content.to_string(),
77            html: String::new(), // Will be filled by markdown pipeline or Tera
78            reading_time,
79            word_count,
80            encrypted_content: None, // Will be filled if frontmatter.encrypted is true
81            has_encrypted_blocks: false, // Will be set if :::encrypted blocks found
82            content_type,
83            source_path: path.to_path_buf(),
84            source_dir: None, // Set by caller for directory-based posts
85        })
86    }
87
88    /// Get the effective slug (frontmatter override or file-based)
89    pub fn slug(&self) -> &str {
90        self.frontmatter.slug.as_deref().unwrap_or(&self.file_slug)
91    }
92
93    /// Get the slugified title
94    fn title_slug(&self) -> String {
95        self.frontmatter
96            .title
97            .to_lowercase()
98            .chars()
99            .map(|c| if c.is_alphanumeric() { c } else { '-' })
100            .collect::<String>()
101            .split('-')
102            .filter(|s| !s.is_empty())
103            .collect::<Vec<_>>()
104            .join("-")
105    }
106
107    /// Resolve a permalink pattern to an actual URL
108    /// Patterns: :year, :month, :day, :slug, :title, :section
109    fn resolve_pattern(&self, pattern: &str) -> String {
110        let mut url = pattern.to_string();
111
112        // Replace :section
113        url = url.replace(":section", &self.section);
114
115        // Replace :slug
116        url = url.replace(":slug", self.slug());
117
118        // Replace :title
119        url = url.replace(":title", &self.title_slug());
120
121        // Replace date parts if date exists
122        if let Some(date) = self.frontmatter.date {
123            url = url.replace(":year", &date.format("%Y").to_string());
124            url = url.replace(":month", &date.format("%m").to_string());
125            url = url.replace(":day", &date.format("%d").to_string());
126        } else {
127            // Remove date patterns if no date
128            url = url.replace(":year", "");
129            url = url.replace(":month", "");
130            url = url.replace(":day", "");
131        }
132
133        // Clean up double slashes
134        while url.contains("//") {
135            url = url.replace("//", "/");
136        }
137
138        // Ensure leading slash
139        if !url.starts_with('/') {
140            url = format!("/{}", url);
141        }
142
143        // Ensure trailing slash
144        if !url.ends_with('/') {
145            url = format!("{}/", url);
146        }
147
148        url
149    }
150
151    /// Get the URL for this post, resolving permalink patterns
152    /// Priority: frontmatter permalink > config pattern > default
153    pub fn url(&self, config: &Config) -> String {
154        // 1. Frontmatter permalink (highest priority)
155        if let Some(permalink) = &self.frontmatter.permalink {
156            return self.resolve_pattern(permalink);
157        }
158
159        // 2. Config pattern for this section
160        if let Some(pattern) = config.permalinks.sections.get(&self.section) {
161            return self.resolve_pattern(pattern);
162        }
163
164        // 3. Default: /:section/:slug/
165        self.resolve_pattern("/:section/:slug/")
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::content::frontmatter::Frontmatter;
173    use chrono::NaiveDate;
174
175    fn make_post(section: &str, file_slug: &str, frontmatter: Frontmatter) -> Post {
176        Post {
177            file_slug: file_slug.to_string(),
178            section: section.to_string(),
179            frontmatter,
180            content: String::new(),
181            html: String::new(),
182            reading_time: 1,
183            word_count: 100,
184            encrypted_content: None,
185            has_encrypted_blocks: false,
186            content_type: ContentType::Markdown,
187            source_path: PathBuf::new(),
188            source_dir: None,
189        }
190    }
191
192    fn make_frontmatter(title: &str, date: Option<NaiveDate>) -> Frontmatter {
193        Frontmatter {
194            title: title.to_string(),
195            description: None,
196            date,
197            tags: None,
198            draft: None,
199            image: None,
200            template: None,
201            slug: None,
202            permalink: None,
203            encrypted: false,
204            password: None,
205        }
206    }
207
208    fn make_config() -> Config {
209        Config::from_data(crate::config::ConfigData {
210            site: crate::config::SiteConfig {
211                title: "Test".to_string(),
212                description: "Test".to_string(),
213                base_url: "https://example.com".to_string(),
214                author: "Test".to_string(),
215            },
216            seo: crate::config::SeoConfig {
217                twitter_handle: None,
218                default_og_image: None,
219            },
220            build: crate::config::BuildConfig {
221                output_dir: "dist".to_string(),
222                minify_css: false,
223                css_output: "rs.css".to_string(),
224            },
225            images: crate::config::ImagesConfig {
226                quality: 85.0,
227                scale_factor: 1.0,
228            },
229            highlight: Default::default(),
230            paths: Default::default(),
231            templates: Default::default(),
232            permalinks: Default::default(),
233            encryption: Default::default(),
234            graph: Default::default(),
235            rss: Default::default(),
236            text: Default::default(),
237            sections: Default::default(),
238        })
239    }
240
241    #[test]
242    fn test_default_permalink() {
243        let fm = make_frontmatter("Hello World", None);
244        let post = make_post("blog", "hello-world", fm);
245        let config = make_config();
246
247        assert_eq!(post.url(&config), "/blog/hello-world/");
248    }
249
250    #[test]
251    fn test_permalink_with_date() {
252        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
253        let fm = make_frontmatter("Hello World", Some(date));
254        let post = make_post("blog", "hello-world", fm);
255
256        assert_eq!(
257            post.resolve_pattern("/:year/:month/:day/:slug/"),
258            "/2024/01/15/hello-world/"
259        );
260        assert_eq!(
261            post.resolve_pattern("/:year/:month/:slug/"),
262            "/2024/01/hello-world/"
263        );
264        assert_eq!(post.resolve_pattern("/:year/:slug/"), "/2024/hello-world/");
265    }
266
267    #[test]
268    fn test_permalink_with_section() {
269        let fm = make_frontmatter("Hello World", None);
270        let post = make_post("projects", "my-project", fm);
271
272        assert_eq!(
273            post.resolve_pattern("/:section/:slug/"),
274            "/projects/my-project/"
275        );
276    }
277
278    #[test]
279    fn test_permalink_with_title() {
280        let fm = make_frontmatter("Hello World!", None);
281        let post = make_post("blog", "hello-world", fm);
282
283        assert_eq!(post.resolve_pattern("/:title/"), "/hello-world/");
284    }
285
286    #[test]
287    fn test_frontmatter_slug_override() {
288        let mut fm = make_frontmatter("Hello World", None);
289        fm.slug = Some("custom-slug".to_string());
290        let post = make_post("blog", "hello-world", fm);
291        let config = make_config();
292
293        assert_eq!(post.slug(), "custom-slug");
294        assert_eq!(post.url(&config), "/blog/custom-slug/");
295    }
296
297    #[test]
298    fn test_frontmatter_permalink_override() {
299        let mut fm = make_frontmatter("Hello World", None);
300        fm.permalink = Some("/custom/path/".to_string());
301        let post = make_post("blog", "hello-world", fm);
302        let config = make_config();
303
304        assert_eq!(post.url(&config), "/custom/path/");
305    }
306
307    #[test]
308    fn test_frontmatter_permalink_pattern() {
309        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
310        let mut fm = make_frontmatter("Hello World", Some(date));
311        fm.permalink = Some("/:year/:month/:slug/".to_string());
312        let post = make_post("blog", "hello-world", fm);
313        let config = make_config();
314
315        assert_eq!(post.url(&config), "/2024/01/hello-world/");
316    }
317
318    #[test]
319    fn test_config_permalink_pattern() {
320        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
321        let fm = make_frontmatter("Hello World", Some(date));
322        let post = make_post("blog", "hello-world", fm);
323
324        let mut config = make_config();
325        config
326            .permalinks
327            .sections
328            .insert("blog".to_string(), "/:year/:month/:slug/".to_string());
329
330        assert_eq!(post.url(&config), "/2024/01/hello-world/");
331    }
332
333    #[test]
334    fn test_permalink_priority() {
335        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
336        let mut fm = make_frontmatter("Hello World", Some(date));
337        fm.permalink = Some("/frontmatter-wins/".to_string());
338        let post = make_post("blog", "hello-world", fm);
339
340        let mut config = make_config();
341        config
342            .permalinks
343            .sections
344            .insert("blog".to_string(), "/:year/:slug/".to_string());
345
346        // Frontmatter should win over config
347        assert_eq!(post.url(&config), "/frontmatter-wins/");
348    }
349
350    #[test]
351    fn test_permalink_cleans_double_slashes() {
352        let fm = make_frontmatter("Hello World", None);
353        let post = make_post("blog", "hello-world", fm);
354
355        // Missing date should not leave double slashes
356        assert_eq!(post.resolve_pattern("/:year/:slug/"), "/hello-world/");
357    }
358
359    #[test]
360    fn test_permalink_ensures_slashes() {
361        let fm = make_frontmatter("Hello World", None);
362        let post = make_post("blog", "hello-world", fm);
363
364        // Should add leading and trailing slashes
365        assert_eq!(post.resolve_pattern(":section/:slug"), "/blog/hello-world/");
366    }
367}