diff --git a/src/markdown.rs b/src/markdown.rs index 1fb279a..551cd05 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -27,7 +27,17 @@ impl MarkdownProcessor { } pub fn render(&self, content: &str) -> String { - let parser = MarkdownParser::new_ext(content, self.options); + let content_without_frontmatter = if let Some(stripped) = content.strip_prefix("---") { + if let Some(end_idx) = stripped.find("---") { + stripped[end_idx + 3..].trim_start() + } else { + content + } + } else { + content + }; + + let parser = MarkdownParser::new_ext(content_without_frontmatter, self.options); let mut events = Vec::new(); let mut code_buffer = String::new(); let mut in_code_block = false; diff --git a/src/post.rs b/src/post.rs index 18a99f7..a453b3b 100644 --- a/src/post.rs +++ b/src/post.rs @@ -1,12 +1,13 @@ -use crate::config::Config; -use crate::errors::Error; -use crate::markdown::MarkdownProcessor; use chrono::{Local, NaiveDate}; +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; -use yaml_front_matter::Document; -use yaml_front_matter::YamlFrontMatter; +use yaml_front_matter::{Document, YamlFrontMatter}; + +use crate::config::Config; +use crate::errors::Error; +use crate::markdown::MarkdownProcessor; #[derive(Debug, Serialize)] pub struct Post { @@ -26,11 +27,14 @@ impl Post { message: e.to_string(), })?; - Ok(Self { + let mut post = Self { metadata: doc.metadata, content: doc.content.clone(), - html_content: md_proc.render(&content), - }) + html_content: md_proc.render(&doc.content), + }; + + post.metadata.read_time = calculate_read_time(&doc.content); + Ok(post) } // Get the assets directory for this post pub fn assets_dir(&self, config: &Config) -> PathBuf { @@ -88,6 +92,8 @@ pub struct PostMetadata { pub preview: String, #[serde(deserialize_with = "validate_and_slugify")] pub slug: String, + #[serde(default)] + pub read_time: u32, } fn default_author() -> String { @@ -116,6 +122,42 @@ where Ok(slug) } +// Calculate read time in minutes based on word count +fn calculate_read_time(content: &str) -> u32 { + const WORDS_PER_MINUTE: u32 = 200; // Average adult reading speed + + // Create a markdown parser with basic options + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + + let parser = Parser::new_ext(content, options); + + // Count words only in actual content, excluding code blocks and YAML frontmatter + let mut word_count = 0; + let mut in_code_block = false; + + for event in parser { + match event { + Event::Start(Tag::CodeBlock(_)) => in_code_block = true, + Event::End(TagEnd::CodeBlock) => in_code_block = false, + Event::Text(text) if !in_code_block => { + // Count words in text content + word_count += text.split_whitespace().count(); + } + _ => {} + } + } + + // Calculate minutes rounded up to the nearest minute + let minutes = (word_count as f32 / WORDS_PER_MINUTE as f32).ceil() as u32; + if minutes == 0 { + 1 + } else { + minutes + } +} + pub fn slugify(text: &str) -> String { let slug = text .to_lowercase() @@ -180,10 +222,10 @@ slug: "{}" {} {}"#, - title, + title.trim(), date, - slug, - outline.as_ref().unwrap_or(&String::new()), + slug.trim(), + outline.as_ref().map(|o| o.trim()).unwrap_or(""), if outline.is_some() { "" } else { @@ -365,3 +407,28 @@ slug: "test-post" assert_eq!(doc.metadata.preview, ""); } } + +#[test] +fn test_read_time_in_post_metadata() { + let content = r#"--- +title: "Test Post" +date: "2024-01-01" +slug: "test-post" +--- + +This is a test post with enough words to make it take at least one minute to read. +Let's add some more text to make sure we have enough content to test the read time calculation. +We'll keep adding words until we have enough for a proper test of the functionality."#; + + let temp_dir = tempfile::TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test-post.md"); + fs::write(&test_file, content).unwrap(); + + let md_proc = MarkdownProcessor::new(); + let post = Post::new_from_path(&test_file, &md_proc).unwrap(); + + assert!( + post.metadata.read_time > 0, + "Read time should be calculated and greater than 0" + ); +} diff --git a/src/serve.rs b/src/serve.rs index 419f100..df42b76 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -1,13 +1,14 @@ +use crate::generator::SiteGenerator; use actix_files::Files; use actix_web::middleware::Logger; use actix_web::{web, App, HttpResponse, HttpServer}; use notify::{RecursiveMode, Watcher}; -use std::process::Command; use std::sync::mpsc; use std::time::{Duration, Instant}; use crate::config::Config; +use crate::errors::Error; pub struct Server { config: Config, @@ -47,6 +48,7 @@ impl Server { } } + let config_for_rebuild = self.config.clone(); let _watcher_handler = std::thread::spawn(move || { let mut last_build = Instant::now(); let debounce_duration = Duration::from_millis(500); @@ -57,18 +59,14 @@ impl Server { if last_build.elapsed() >= debounce_duration { println!("🔄 Rebuilding site..."); - match Command::new("termv") - .arg("build") - .arg("--target-dir") - .arg(&config_clone.site_dir) - .status() - { - Ok(status) if status.success() => { + + // Use internal generator instead of spawning process + match rebuild_site(&config_for_rebuild) { + Ok(_) => { println!("✨ Site rebuilt successfully!"); last_build = Instant::now(); } - Ok(status) => eprintln!("Build failed with status: {}", status), - Err(e) => eprintln!("Failed to execute build: {}", e), + Err(e) => eprintln!("Build failed: {}", e), } } } @@ -97,6 +95,11 @@ impl Server { } } +fn rebuild_site(config: &Config) -> Result<(), Error> { + let generator = SiteGenerator::new(config)?; + generator.generate_site() +} + pub fn serve(config: Config) -> Result<(), Box> { let output_dir = config.output_dir();