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 },
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 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 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 assert_eq!(post.resolve_pattern(":section/:slug"), "/blog/hello-world/");
353 }
354}