New template engine

This commit is contained in:
2025-10-25 17:30:20 +08:00
parent c0ac63b73c
commit 190667e5a5
3 changed files with 203 additions and 129 deletions

39
Cargo.lock generated
View File

@@ -2,15 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -112,7 +103,6 @@ dependencies = [
"chrono", "chrono",
"once_cell", "once_cell",
"pulldown-cmark", "pulldown-cmark",
"regex",
"serde", "serde",
"tokio", "tokio",
"tower 0.4.13", "tower 0.4.13",
@@ -526,35 +516,6 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "regex"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"

View File

@@ -11,5 +11,4 @@ tower-http = { version = "0.5", features = ["fs"] }
pulldown-cmark = "0.12" pulldown-cmark = "0.12"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
chrono = "0.4" chrono = "0.4"
regex = "1.10"
once_cell = "1.19" once_cell = "1.19"

View File

@@ -1,5 +1,5 @@
use crate::post_manager::PostManager; use crate::post_manager::PostManager;
use regex::Regex; use std::collections::HashMap;
use std::fs; use std::fs;
pub fn render_template( pub fn render_template(
@@ -7,105 +7,195 @@ pub fn render_template(
post_manager: &PostManager, post_manager: &PostManager,
current_post: Option<&str>, current_post: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let mut result = template.to_string(); parse_template(template, post_manager, current_post, 0)
}
// Process includes first fn parse_template(
result = process_includes(&result)?; template: &str,
post_manager: &PostManager,
// Process post-html tag current_post: Option<&str>,
if let Some(post_name) = current_post { depth: usize,
result = process_post_html(&result, post_manager, post_name)?; ) -> Result<String, String> {
const MAX_DEPTH: usize = 10;
if depth > MAX_DEPTH {
return Err("Too many nested includes (max 10)".to_string());
} }
// Process post-list tag let mut result = String::new();
result = process_post_list(&result, post_manager)?; let chars: Vec<char> = 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)?;
result.push_str(&content);
i = end_pos;
continue;
}
}
// Regular character
result.push(chars[i]);
i += 1;
}
Ok(result) Ok(result)
} }
fn process_includes(template: &str) -> Result<String, String> { fn parse_tag(chars: &[char], start: usize) -> Option<(String, HashMap<String, String>, usize)> {
let include_regex = Regex::new(r#"\$<include\s+src="([^"]+)"\s*/>"#).unwrap(); let mut i = start + 2; // Skip $<
let mut result = template.to_string();
let mut iteration = 0; // Skip whitespace
const MAX_ITERATIONS: usize = 10; 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 { loop {
iteration += 1; // Skip whitespace
if iteration > MAX_ITERATIONS { while i < chars.len() && chars[i].is_whitespace() {
return Err("Too many nested includes (max 10)".to_string()); i += 1;
} }
let result_copy = result.clone(); // Check for end of tag
let captures: Vec<_> = include_regex.captures_iter(&result_copy).collect(); if i >= chars.len() {
return None;
if captures.is_empty() {
break;
} }
for cap in captures { if chars[i] == '/' {
let full_match = cap.get(0).unwrap().as_str(); i += 1;
let src = cap.get(1).unwrap().as_str(); 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);
}
}
fn process_tag(
tag_name: &str,
attrs: &HashMap<String, String>,
post_manager: &PostManager,
current_post: Option<&str>,
depth: usize,
) -> Result<String, String> {
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 include_path = format!("templates/{}", src);
let content = fs::read_to_string(&include_path) let content = fs::read_to_string(&include_path)
.map_err(|e| format!("Failed to read include file '{}': {}", src, e))?; .map_err(|e| format!("Failed to read include file '{}': {}", src, e))?;
result = result.replace(full_match, &content); // Recursively parse the included content
parse_template(&content, post_manager, current_post, depth + 1)
} }
}
Ok(result) "post-html" => {
} let post_name = current_post
.ok_or_else(|| "post-html tag used outside of post context".to_string())?;
fn process_post_html( let post = post_manager
template: &str, .get_post(post_name)
post_manager: &PostManager, .ok_or_else(|| format!("Post '{}' not found", post_name))?;
post_name: &str,
) -> Result<String, String> {
let post_html_regex = Regex::new(r"\$<post-html\s*/?>").unwrap();
let post = post_manager Ok(post.html_content.clone())
.get_post(post_name)
.ok_or_else(|| format!("Post '{}' not found", post_name))?;
Ok(post_html_regex.replace_all(template, post.html_content.as_str()).to_string())
}
fn process_post_list(
template: &str,
post_manager: &PostManager,
) -> Result<String, String> {
let post_list_regex = Regex::new(r#"\$<post-list(?:\s+limit=(\d+))?\s*/>"#).unwrap();
let mut result = template.to_string();
for cap in post_list_regex.captures_iter(template) {
let full_match = cap.get(0).unwrap().as_str();
let limit_attr = cap.get(1).and_then(|m| m.as_str().parse::<usize>().ok());
let limit = limit_attr;
let posts = if let Some(l) = limit {
post_manager.get_posts_limited(l)
} else {
post_manager.get_all_posts()
};
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.name,
post.created_at.format("%Y-%m-%d")
));
} }
list_html.push_str("</ul>");
result = result.replace(full_match, &list_html); "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)
} else {
post_manager.get_all_posts()
};
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.name,
post.created_at.format("%Y-%m-%d")
));
}
list_html.push_str("</ul>");
Ok(list_html)
}
_ => Err(format!("Unknown tag: {}", tag_name))
} }
Ok(result)
} }
#[cfg(test)] #[cfg(test)]
@@ -113,26 +203,50 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_post_list_regex() { fn test_parse_tag_post_list() {
let regex = Regex::new(r#"\$<post-list(?:\s+limit=(\d+))?\s*/>"#).unwrap(); 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());
assert!(regex.is_match("$<post-list/>")); let chars: Vec<char> = "$<post-list />".chars().collect();
assert!(regex.is_match("$<post-list />")); let result = parse_tag(&chars, 0);
assert!(regex.is_match("$<post-list limit=50/>")); assert!(result.is_some());
assert!(regex.is_match("$<post-list limit=50 />"));
let cap = regex.captures("$<post-list limit=50/>").unwrap(); let chars: Vec<char> = r#"$<post-list limit="50"/>"#.chars().collect();
assert_eq!(cap.get(1).unwrap().as_str(), "50"); 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] #[test]
fn test_include_regex() { fn test_parse_tag_include() {
let regex = Regex::new(r#"\$<include\s+src="([^"]+)"\s*/>"#).unwrap(); 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()));
assert!(regex.is_match(r#"$<include src="header.html"/>"#)); let chars: Vec<char> = r#"$<include src="header.html" />"#.chars().collect();
assert!(regex.is_match(r#"$<include src="header.html" />"#)); 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 cap = regex.captures(r#"$<include src="header.html"/>"#).unwrap(); #[test]
assert_eq!(cap.get(1).unwrap().as_str(), "header.html"); 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());
} }
} }