Initial blog setup

This commit is contained in:
2025-10-29 20:29:52 +08:00
commit 9229313f9b
17 changed files with 2502 additions and 0 deletions

61
src/git_tracker.rs Normal file
View 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
View 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
View 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
View 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());
}
}