rs_web/content/
post.rs

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