use crate::git_tracker::get_git_version; use crate::post_manager::PostManager; use std::collections::HashMap; use std::fs; use std::future::Future; use std::pin::Pin; pub async fn render_template( template: &str, post_manager: &PostManager, current_post: Option<&str>, ) -> Result { parse_template(template, post_manager, current_post, 0).await } fn parse_template<'a>( template: &'a str, post_manager: &'a PostManager, current_post: Option<&'a str>, depth: usize, ) -> Pin> + Send + 'a>> { Box::pin(async move { parse_template_impl(template, post_manager, current_post, depth).await }) } async fn parse_template_impl( template: &str, post_manager: &PostManager, current_post: Option<&str>, depth: usize, ) -> Result { const MAX_DEPTH: usize = 10; if depth > MAX_DEPTH { return Err("Too many nested includes (max 10)".to_string()); } let mut result = String::new(); let chars: Vec = template.chars().collect(); let mut i = 0; while i < chars.len() { // Check for $< tag start if i + 1 < chars.len() && chars[i] == '$' && chars[i + 1] == '<' { // Try to parse tag if let Some((tag_name, attrs, end_pos)) = parse_tag(&chars, i) { // Process tag immediately based on type let content = process_tag(&tag_name, &attrs, post_manager, current_post, depth).await?; result.push_str(&content); i = end_pos; continue; } } // Regular character result.push(chars[i]); i += 1; } Ok(result) } fn parse_tag(chars: &[char], start: usize) -> Option<(String, HashMap, usize)> { let mut i = start + 2; // Skip $< // Skip whitespace while i < chars.len() && chars[i].is_whitespace() { i += 1; } // Parse tag name let mut tag_name = String::new(); while i < chars.len() && !chars[i].is_whitespace() && chars[i] != '/' && chars[i] != '>' { tag_name.push(chars[i]); i += 1; } if tag_name.is_empty() { return None; } // Parse attributes let mut attrs = HashMap::new(); loop { // Skip whitespace while i < chars.len() && chars[i].is_whitespace() { i += 1; } // Check for end of tag if i >= chars.len() { return None; } if chars[i] == '/' { i += 1; if i < chars.len() && chars[i] == '>' { return Some((tag_name, attrs, i + 1)); } return None; } if chars[i] == '>' { return Some((tag_name, attrs, i + 1)); } // Parse attribute name let mut attr_name = String::new(); while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() { attr_name.push(chars[i]); i += 1; } if attr_name.is_empty() { return None; } // Skip whitespace while i < chars.len() && chars[i].is_whitespace() { i += 1; } // Expect = if i >= chars.len() || chars[i] != '=' { return None; } i += 1; // Skip whitespace while i < chars.len() && chars[i].is_whitespace() { i += 1; } // Parse attribute value (quoted) if i >= chars.len() || chars[i] != '"' { return None; } i += 1; // Skip opening quote let mut attr_value = String::new(); while i < chars.len() && chars[i] != '"' { attr_value.push(chars[i]); i += 1; } if i >= chars.len() { return None; } i += 1; // Skip closing quote attrs.insert(attr_name, attr_value); } } async fn process_tag( tag_name: &str, attrs: &HashMap, post_manager: &PostManager, current_post: Option<&str>, depth: usize, ) -> Result { match tag_name { "include" => { let src = attrs .get("src") .ok_or_else(|| "include tag missing 'src' attribute".to_string())?; let include_path = format!("templates/{}", src); let content = fs::read_to_string(&include_path) .map_err(|e| format!("Failed to read include file '{}': {}", src, e))?; // Recursively parse the included content parse_template(&content, post_manager, current_post, depth + 1).await } "post-html" => { let post_name = current_post .ok_or_else(|| "post-html tag used outside of post context".to_string())?; let post = post_manager .get_post(post_name) .await .ok_or_else(|| format!("Post '{}' not found", post_name))?; Ok(post.html_content.clone()) } "title" => { if let Some(post_name) = current_post { let post = post_manager .get_post(post_name) .await .ok_or_else(|| format!("Post '{}' not found", post_name))?; let none: String = "".to_string(); let pre = attrs.get("pre").unwrap_or(&none); let postfix = attrs.get("post").unwrap_or(&none); Ok(format!("{}{}{}", pre, post.title, postfix)) } else { let def = attrs .get("default") .and_then(|s| Some(s.clone())) .unwrap_or("".to_string()); Ok(def.to_string()) } } "updated" => { // Insert the last updated time of the current post (or most recent post) let post_ts = if let Some(p) = current_post { post_manager .get_post(p) .await .ok_or_else(|| format!("Post '{}' not found", p))? .modified_at } else { post_manager.get_update_timestamp().await? }; Ok(post_ts.format("%Y-%m-%d").to_string()) } "version" => { let git_dir = post_manager.get_root_dir().clone(); let git_version = get_git_version(&git_dir)?; Ok(format!("{}/{}", git_version.branch, git_version.commit)) } "post-list" => { let limit = attrs.get("limit").and_then(|s| s.parse::<usize>().ok()); let posts = if let Some(l) = limit { post_manager.get_posts_limited(l).await } else { post_manager.get_all_posts().await }; let mut list_html = String::from("<ul class=\"post-list\">\n"); for post in posts { list_html.push_str(&format!( " <li><a href=\"/p/{}\">{}</a> <span class=\"date\">{}</span></li>\n", post.name, post.title, post.created_at.format("%Y-%m-%d") )); } list_html.push_str("</ul>"); Ok(list_html) } _ => Err(format!("Unknown tag: {}", tag_name)), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_tag_post_list() { let chars: Vec<char> = "$<post-list/>".chars().collect(); let result = parse_tag(&chars, 0); assert!(result.is_some()); let (tag_name, attrs, _) = result.unwrap(); assert_eq!(tag_name, "post-list"); assert!(attrs.is_empty()); let chars: Vec<char> = "$<post-list />".chars().collect(); let result = parse_tag(&chars, 0); assert!(result.is_some()); let chars: Vec<char> = r#"$<post-list limit="50"/>"#.chars().collect(); let result = parse_tag(&chars, 0); assert!(result.is_some()); let (tag_name, attrs, _) = result.unwrap(); assert_eq!(tag_name, "post-list"); assert_eq!(attrs.get("limit"), Some(&"50".to_string())); } #[test] fn test_parse_tag_include() { let chars: Vec<char> = r#"$<include src="header.html"/>"#.chars().collect(); let result = parse_tag(&chars, 0); assert!(result.is_some()); let (tag_name, attrs, _) = result.unwrap(); assert_eq!(tag_name, "include"); assert_eq!(attrs.get("src"), Some(&"header.html".to_string())); let chars: Vec<char> = r#"$<include src="header.html" />"#.chars().collect(); let result = parse_tag(&chars, 0); assert!(result.is_some()); let (tag_name, attrs, _) = result.unwrap(); assert_eq!(tag_name, "include"); assert_eq!(attrs.get("src"), Some(&"header.html".to_string())); } #[test] fn test_parse_tag_post_html() { let chars: Vec<char> = "$<post-html/>".chars().collect(); let result = parse_tag(&chars, 0); assert!(result.is_some()); let (tag_name, attrs, _) = result.unwrap(); assert_eq!(tag_name, "post-html"); assert!(attrs.is_empty()); } }