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