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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ContentType {
11 Markdown,
13 Html,
15}
16
17#[derive(Debug, Clone)]
18pub struct Post {
19 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 pub encrypted_content: Option<EncryptedContent>,
29 pub has_encrypted_blocks: bool,
31 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 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 let file_slug = path
51 .file_stem()
52 .and_then(|s| s.to_str())
53 .map(|s| {
54 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; Ok(Self {
67 file_slug,
68 section: section.to_string(),
69 frontmatter,
70 content: content.to_string(),
71 html: String::new(), reading_time,
73 word_count,
74 encrypted_content: None, has_encrypted_blocks: false, content_type,
77 })
78 }
79
80 pub fn slug(&self) -> &str {
82 self.frontmatter.slug.as_deref().unwrap_or(&self.file_slug)
83 }
84
85 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 fn resolve_pattern(&self, pattern: &str) -> String {
102 let mut url = pattern.to_string();
103
104 url = url.replace(":section", &self.section);
106
107 url = url.replace(":slug", self.slug());
109
110 url = url.replace(":title", &self.title_slug());
112
113 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 url = url.replace(":year", "");
121 url = url.replace(":month", "");
122 url = url.replace(":day", "");
123 }
124
125 while url.contains("//") {
127 url = url.replace("//", "/");
128 }
129
130 if !url.starts_with('/') {
132 url = format!("/{}", url);
133 }
134
135 if !url.ends_with('/') {
137 url = format!("{}/", url);
138 }
139
140 url
141 }
142
143 pub fn url(&self, config: &Config) -> String {
146 if let Some(permalink) = &self.frontmatter.permalink {
148 return self.resolve_pattern(permalink);
149 }
150
151 if let Some(pattern) = config.permalinks.sections.get(&self.section) {
153 return self.resolve_pattern(pattern);
154 }
155
156 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 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 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 assert_eq!(post.resolve_pattern(":section/:slug"), "/blog/hello-world/");
355 }
356}