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