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