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            },
214            images: crate::config::ImagesConfig {
215                quality: 85.0,
216                scale_factor: 1.0,
217            },
218            highlight: Default::default(),
219            paths: Default::default(),
220            templates: Default::default(),
221            permalinks: Default::default(),
222            encryption: Default::default(),
223            graph: Default::default(),
224            rss: Default::default(),
225        }
226    }
227
228    #[test]
229    fn test_default_permalink() {
230        let fm = make_frontmatter("Hello World", None);
231        let post = make_post("blog", "hello-world", fm);
232        let config = make_config();
233
234        assert_eq!(post.url(&config), "/blog/hello-world/");
235    }
236
237    #[test]
238    fn test_permalink_with_date() {
239        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
240        let fm = make_frontmatter("Hello World", Some(date));
241        let post = make_post("blog", "hello-world", fm);
242
243        assert_eq!(
244            post.resolve_pattern("/:year/:month/:day/:slug/"),
245            "/2024/01/15/hello-world/"
246        );
247        assert_eq!(
248            post.resolve_pattern("/:year/:month/:slug/"),
249            "/2024/01/hello-world/"
250        );
251        assert_eq!(post.resolve_pattern("/:year/:slug/"), "/2024/hello-world/");
252    }
253
254    #[test]
255    fn test_permalink_with_section() {
256        let fm = make_frontmatter("Hello World", None);
257        let post = make_post("projects", "my-project", fm);
258
259        assert_eq!(
260            post.resolve_pattern("/:section/:slug/"),
261            "/projects/my-project/"
262        );
263    }
264
265    #[test]
266    fn test_permalink_with_title() {
267        let fm = make_frontmatter("Hello World!", None);
268        let post = make_post("blog", "hello-world", fm);
269
270        assert_eq!(post.resolve_pattern("/:title/"), "/hello-world/");
271    }
272
273    #[test]
274    fn test_frontmatter_slug_override() {
275        let mut fm = make_frontmatter("Hello World", None);
276        fm.slug = Some("custom-slug".to_string());
277        let post = make_post("blog", "hello-world", fm);
278        let config = make_config();
279
280        assert_eq!(post.slug(), "custom-slug");
281        assert_eq!(post.url(&config), "/blog/custom-slug/");
282    }
283
284    #[test]
285    fn test_frontmatter_permalink_override() {
286        let mut fm = make_frontmatter("Hello World", None);
287        fm.permalink = Some("/custom/path/".to_string());
288        let post = make_post("blog", "hello-world", fm);
289        let config = make_config();
290
291        assert_eq!(post.url(&config), "/custom/path/");
292    }
293
294    #[test]
295    fn test_frontmatter_permalink_pattern() {
296        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
297        let mut fm = make_frontmatter("Hello World", Some(date));
298        fm.permalink = Some("/:year/:month/:slug/".to_string());
299        let post = make_post("blog", "hello-world", fm);
300        let config = make_config();
301
302        assert_eq!(post.url(&config), "/2024/01/hello-world/");
303    }
304
305    #[test]
306    fn test_config_permalink_pattern() {
307        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
308        let fm = make_frontmatter("Hello World", Some(date));
309        let post = make_post("blog", "hello-world", fm);
310
311        let mut config = make_config();
312        config
313            .permalinks
314            .sections
315            .insert("blog".to_string(), "/:year/:month/:slug/".to_string());
316
317        assert_eq!(post.url(&config), "/2024/01/hello-world/");
318    }
319
320    #[test]
321    fn test_permalink_priority() {
322        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
323        let mut fm = make_frontmatter("Hello World", Some(date));
324        fm.permalink = Some("/frontmatter-wins/".to_string());
325        let post = make_post("blog", "hello-world", fm);
326
327        let mut config = make_config();
328        config
329            .permalinks
330            .sections
331            .insert("blog".to_string(), "/:year/:slug/".to_string());
332
333        // Frontmatter should win over config
334        assert_eq!(post.url(&config), "/frontmatter-wins/");
335    }
336
337    #[test]
338    fn test_permalink_cleans_double_slashes() {
339        let fm = make_frontmatter("Hello World", None);
340        let post = make_post("blog", "hello-world", fm);
341
342        // Missing date should not leave double slashes
343        assert_eq!(post.resolve_pattern("/:year/:slug/"), "/hello-world/");
344    }
345
346    #[test]
347    fn test_permalink_ensures_slashes() {
348        let fm = make_frontmatter("Hello World", None);
349        let post = make_post("blog", "hello-world", fm);
350
351        // Should add leading and trailing slashes
352        assert_eq!(post.resolve_pattern(":section/:slug"), "/blog/hello-world/");
353    }
354}