Initial blog setup
This commit is contained in:
61
src/git_tracker.rs
Normal file
61
src/git_tracker.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitVersion {
|
||||
pub branch: String,
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
pub fn get_git_version(git_path: &PathBuf) -> Result<GitVersion, String> {
|
||||
let head_path = git_path.join(".git/HEAD");
|
||||
|
||||
// Read HEAD file
|
||||
let head_content = fs::read_to_string(&head_path)
|
||||
.map_err(|e| format!("Failed to read .git/HEAD: {}", e))?;
|
||||
|
||||
let head_content = head_content.trim();
|
||||
|
||||
// Check if HEAD points to a ref
|
||||
if head_content.starts_with("ref:") {
|
||||
let ref_path = head_content.strip_prefix("ref:").unwrap().trim();
|
||||
let full_ref_path = git_path.join(".git").join(ref_path);
|
||||
|
||||
// Read the ref file to get the commit hash
|
||||
let commit_hash = fs::read_to_string(&full_ref_path)
|
||||
.map_err(|e| format!("Failed to read ref file: {}", e))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Extract branch name from ref path
|
||||
let branch = ref_path
|
||||
.strip_prefix("refs/heads/")
|
||||
.unwrap_or(ref_path)
|
||||
.to_string();
|
||||
|
||||
Ok(GitVersion {
|
||||
branch,
|
||||
commit: commit_hash,
|
||||
})
|
||||
} else {
|
||||
// Detached HEAD state - just use the commit hash
|
||||
Ok(GitVersion {
|
||||
branch: "detached".to_string(),
|
||||
commit: head_content.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_git_version() {
|
||||
// This test will only work if run in a git repository
|
||||
if let Ok(version) = get_git_version(".") {
|
||||
assert!(!version.commit.is_empty());
|
||||
assert!(!version.branch.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
180
src/main.rs
Normal file
180
src/main.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
mod git_tracker;
|
||||
mod post_manager;
|
||||
mod template_engine;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use clap::Parser;
|
||||
use std::sync::Arc;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Disable caching of markdown rendering
|
||||
#[arg(long)]
|
||||
no_cache: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
post_manager: Arc<RwLock<post_manager::PostManager>>,
|
||||
update_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
let post_manager = Arc::new(RwLock::new(
|
||||
post_manager::PostManager::new(".", args.no_cache).expect("Failed to initialize post manager"),
|
||||
));
|
||||
|
||||
let app_state = AppState {
|
||||
post_manager: post_manager.clone(),
|
||||
update_lock: Arc::new(Mutex::new(())),
|
||||
};
|
||||
|
||||
if args.no_cache {
|
||||
println!("Running with caching disabled");
|
||||
}
|
||||
|
||||
let p_router = Router::new()
|
||||
.route("/:post_name", get(post_handler))
|
||||
.fallback_service(ServeDir::new("posts"));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/update", get(update_handler))
|
||||
.route("/:page", get(all_handler))
|
||||
.nest("/p", p_router)
|
||||
.with_state(app_state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
|
||||
.await
|
||||
.expect("Failed to bind to address");
|
||||
|
||||
println!("Server running on http://127.0.0.1:3000");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Failed to start server");
|
||||
}
|
||||
|
||||
async fn index_handler(State(state): State<AppState>) -> Response {
|
||||
match render_template("index.html", &state).await {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn all_handler(State(state): State<AppState>, Path(page): Path<String>) -> Response {
|
||||
if page.contains("..") {
|
||||
return (StatusCode::NOT_FOUND, "Invalid path").into_response();
|
||||
}
|
||||
match render_template(&format!("page_{}.html", page), &state).await {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(post_name): Path<String>,
|
||||
) -> Response {
|
||||
if post_name.contains("..") {
|
||||
return (StatusCode::NOT_FOUND, "Invalid path").into_response();
|
||||
}
|
||||
|
||||
let manager = state.post_manager.read().await;
|
||||
match manager.get_post(&post_name).await {
|
||||
Some(_post) => {
|
||||
drop(manager);
|
||||
match render_post_template(&state, post_name).await {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "Post not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||
// Acquire lock to prevent concurrent updates
|
||||
let _lock = state.update_lock.lock().await;
|
||||
|
||||
// Run git pull --autostash
|
||||
let output = Command::new("git")
|
||||
.arg("pull")
|
||||
.arg("--autostash")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
eprintln!("Git pull failed: {}", stderr);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Git pull failed: {}", stderr),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("Git pull output: {}", stdout);
|
||||
|
||||
// Refresh posts
|
||||
let mut manager = state.post_manager.write().await;
|
||||
match manager.refresh_posts() {
|
||||
Ok(_) => {
|
||||
println!("Successfully refreshed log pages");
|
||||
(StatusCode::OK, "Update successful: pulled changes and refreshed log pages")
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to refresh posts: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to refresh posts: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to execute git pull: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to execute git pull: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn render_template(template_name: &str, state: &AppState) -> Result<String, String> {
|
||||
let template_path = format!("templates/{}", template_name);
|
||||
let template_content = std::fs::read_to_string(&template_path)
|
||||
.map_err(|e| format!("Failed to read template: {}", e))?;
|
||||
|
||||
let manager = state.post_manager.read().await;
|
||||
template_engine::render_template(&template_content, &*manager, None).await
|
||||
}
|
||||
|
||||
async fn render_post_template(state: &AppState, post_name: String) -> Result<String, String> {
|
||||
let template_content = std::fs::read_to_string("templates/post.html")
|
||||
.map_err(|e| format!("Failed to read post template: {}", e))?;
|
||||
|
||||
let manager = state.post_manager.read().await;
|
||||
template_engine::render_template(&template_content, &*manager, Some(&post_name)).await
|
||||
}
|
||||
264
src/post_manager.rs
Normal file
264
src/post_manager.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use pulldown_cmark::{html, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Post {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
#[allow(dead_code)]
|
||||
pub filename: String,
|
||||
#[allow(dead_code)]
|
||||
pub markdown_content: String,
|
||||
pub html_content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
#[allow(dead_code)]
|
||||
short_name: String,
|
||||
file_path: String,
|
||||
created_at: DateTime<Utc>,
|
||||
modified_at: DateTime<Utc>,
|
||||
status: i32,
|
||||
}
|
||||
|
||||
pub struct PostManager {
|
||||
root_dir: PathBuf,
|
||||
posts_dir_rel: String,
|
||||
posts: HashMap<String, Arc<RwLock<Post>>>,
|
||||
no_cache: bool,
|
||||
}
|
||||
|
||||
impl PostManager {
|
||||
pub fn new(root_dir: &str, no_cache: bool) -> Result<Self, String> {
|
||||
let root_dir = PathBuf::from(root_dir);
|
||||
let posts_dir_rel = "posts";
|
||||
|
||||
let mut manager = PostManager {
|
||||
root_dir,
|
||||
posts_dir_rel: posts_dir_rel.to_string(),
|
||||
posts: HashMap::new(),
|
||||
no_cache,
|
||||
};
|
||||
|
||||
manager.refresh_posts()?;
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub fn get_root_dir(&self) -> &PathBuf {
|
||||
&self.root_dir
|
||||
}
|
||||
|
||||
fn query_posts(&self) -> Result<HashMap<String, Entry>, String> {
|
||||
let output = Command::new("git")
|
||||
.arg("log")
|
||||
.arg("--raw")
|
||||
.arg("--no-merges")
|
||||
.arg("--pretty=%h - %ad - %s")
|
||||
.arg("--date=unix")
|
||||
.arg("--")
|
||||
.arg(self.posts_dir_rel.clone())
|
||||
.current_dir(&self.root_dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute git whatchanged: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Git whatchanged command failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let log_output = String::from_utf8_lossy(&output.stdout);
|
||||
let mut result: HashMap<String, Entry> = HashMap::new();
|
||||
let mut current_timestamp: Option<DateTime<Utc>> = None;
|
||||
|
||||
for line in log_output.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse commit header line: "f4fcf0e - 1761305168 - New post"
|
||||
if !line.starts_with(':') {
|
||||
if let Some(dash_pos) = line.find(" - ") {
|
||||
let after_first_dash = &line[dash_pos + 3..];
|
||||
if let Some(second_dash_pos) = after_first_dash.find(" - ") {
|
||||
let timestamp_str = after_first_dash[..second_dash_pos].trim();
|
||||
if let Ok(timestamp) = timestamp_str.parse::<i64>() {
|
||||
current_timestamp = DateTime::from_timestamp(timestamp, 0);
|
||||
eprintln!("Timestamp: {:?} ({})", current_timestamp, timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse file change line: ":000000 100644 0000000 6bdad65 A posts/hello-world-2.md"
|
||||
if line.starts_with(':') {
|
||||
eprintln!("Parsing line: {}", line);
|
||||
if let Some(timestamp) = current_timestamp {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 6 {
|
||||
let status = parts[4]; // A (add), D (delete), M (modify)
|
||||
let file_path = parts[5];
|
||||
|
||||
let fn_ = file_path
|
||||
.strip_prefix("posts/")
|
||||
.and_then(|s| s.strip_suffix(".md"));
|
||||
if let Some(name) = fn_ {
|
||||
let entry = result.entry(name.to_string()).or_insert(Entry {
|
||||
short_name: name.to_string(),
|
||||
file_path: file_path.to_string(),
|
||||
created_at: timestamp,
|
||||
modified_at: timestamp,
|
||||
status: if status == "D" { -1 } else { 1 },
|
||||
});
|
||||
|
||||
// Always use the oldest timestamp for posts creation dates
|
||||
entry.created_at = timestamp;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Invalid git log output, expected prior timestamp: {}", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn refresh_posts(&mut self) -> Result<(), String> {
|
||||
self.posts.clear();
|
||||
|
||||
// Get timestamps from git history
|
||||
let query_result = self.query_posts()?;
|
||||
for (name, entry) in query_result {
|
||||
if entry.status != 1 {
|
||||
continue;
|
||||
}
|
||||
let markdown_content = fs::read_to_string(&entry.file_path)
|
||||
.map_err(|e| format!("Failed to read post file: {}", e))?;
|
||||
let (html_content, title) = markdown_to_html(&markdown_content);
|
||||
let post = Post {
|
||||
name: name.clone(),
|
||||
title: title,
|
||||
filename: entry.file_path,
|
||||
markdown_content,
|
||||
html_content,
|
||||
created_at: entry.created_at,
|
||||
modified_at: entry.modified_at,
|
||||
};
|
||||
eprintln!("Loaded post: {} ({})", name, entry.created_at);
|
||||
self.posts.insert(name, Arc::new(RwLock::new(post)));
|
||||
}
|
||||
|
||||
println!("Loaded {} posts", self.posts.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_post(&self, name: &str) -> Option<Post> {
|
||||
let post_lock = self.posts.get(name)?;
|
||||
|
||||
// If no_cache is enabled, always regenerate
|
||||
if self.no_cache {
|
||||
self.refresh_post_cache(name, post_lock).await;
|
||||
}
|
||||
|
||||
let post = post_lock.read().await;
|
||||
Some(post.clone())
|
||||
}
|
||||
|
||||
async fn refresh_post_cache(&self, name: &str, post_lock: &Arc<RwLock<Post>>) {
|
||||
let mut post = post_lock.write().await;
|
||||
let filename = post.filename.clone();
|
||||
|
||||
if let Ok(markdown_content) = fs::read_to_string(&filename) {
|
||||
let (html_content, title) = markdown_to_html(&markdown_content);
|
||||
post.html_content = html_content;
|
||||
post.title = title;
|
||||
post.markdown_content = markdown_content;
|
||||
eprintln!("Refreshed post '{}'", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all posts, sorted by creation date
|
||||
pub async fn get_all_posts(&self) -> Vec<Post> {
|
||||
let mut posts = Vec::new();
|
||||
for (name, post_lock) in &self.posts {
|
||||
// If no_cache is enabled, always regenerate
|
||||
if self.no_cache {
|
||||
self.refresh_post_cache(name, post_lock).await;
|
||||
}
|
||||
|
||||
let post = post_lock.read().await;
|
||||
posts.push(post.clone());
|
||||
}
|
||||
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
posts
|
||||
}
|
||||
|
||||
// Get the timstamp of when the blog was most recently updated
|
||||
// derived from latest post update
|
||||
pub async fn get_update_timestamp(&self) -> Result<DateTime<Utc>, String> {
|
||||
let posts = self.get_all_posts().await;
|
||||
let mut posts_sorted: Vec<&Post> = posts.iter().collect();
|
||||
posts_sorted.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
Ok(posts_sorted
|
||||
.first()
|
||||
.ok_or("No posts found".to_string())?
|
||||
.created_at)
|
||||
}
|
||||
|
||||
pub async fn get_posts_limited(&self, limit: usize) -> Vec<Post> {
|
||||
let mut posts = self.get_all_posts().await;
|
||||
posts.truncate(limit);
|
||||
posts
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown_title(markdown: &str) -> Option<String> {
|
||||
let parser = Parser::new(markdown);
|
||||
let mut in_tag = false;
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(Tag::Heading {
|
||||
level: HeadingLevel::H1,
|
||||
..
|
||||
}) => in_tag = true,
|
||||
Event::End(TagEnd::Heading(HeadingLevel::H1)) => in_tag = false,
|
||||
Event::Text(txt) => {
|
||||
if in_tag {
|
||||
return Some(txt.to_string());
|
||||
}
|
||||
}
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn markdown_to_html(markdown: &str) -> (String, String) {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||
|
||||
let title = markdown_title(markdown).unwrap_or("unknown".to_string());
|
||||
|
||||
let parser = Parser::new_ext(markdown, options);
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
(html_output, title)
|
||||
}
|
||||
308
src/template_engine.rs
Normal file
308
src/template_engine.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
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<String, String> {
|
||||
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<Box<dyn Future<Output = Result<String, String>> + 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<String, String> {
|
||||
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<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).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<String, String>, 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<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 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("<title not set>".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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user