Initial commit
Initial commit Add lua template
This commit is contained in:
68
src/git_tracker.rs
Normal file
68
src/git_tracker.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitVersion {
|
||||
pub branch: String,
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
impl GitVersion {
|
||||
pub fn key(&self) -> String {
|
||||
format!("{}/{}", self.branch, self.commit)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_git_version(git_dir: &str) -> Result<GitVersion, String> {
|
||||
let git_path = Path::new(git_dir);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/main.rs
Normal file
116
src/main.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
mod git_tracker;
|
||||
mod post_manager;
|
||||
mod template_engine;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
post_manager: Arc<RwLock<post_manager::PostManager>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let post_manager = Arc::new(RwLock::new(
|
||||
post_manager::PostManager::new(".").expect("Failed to initialize post manager"),
|
||||
));
|
||||
|
||||
let app_state = AppState {
|
||||
post_manager: post_manager.clone(),
|
||||
};
|
||||
|
||||
// Spawn background task to watch for git changes
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut manager = post_manager.write().await;
|
||||
if let Err(e) = manager.refresh_if_needed() {
|
||||
eprintln!("Error refreshing posts: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/:page", get(all_handler))
|
||||
.route("/p/:post_name", get(post_handler))
|
||||
.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>) -> impl IntoResponse {
|
||||
match render_template("index.html", &state, Some(50)).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>) -> impl IntoResponse {
|
||||
if page.contains("..") {
|
||||
return (StatusCode::NOT_FOUND, "Invalid path").into_response();
|
||||
}
|
||||
match render_template(&format!("page_{}.html", page), &state, None).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>,
|
||||
) -> impl IntoResponse {
|
||||
let manager = state.post_manager.read().await;
|
||||
|
||||
if post_name.contains("..") || post_name.contains("/") {
|
||||
return (StatusCode::NOT_FOUND, "Invalid path").into_response();
|
||||
}
|
||||
match manager.get_post(&post_name) {
|
||||
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 render_template(
|
||||
template_name: &str,
|
||||
state: &AppState,
|
||||
limit: Option<usize>,
|
||||
) -> 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, limit)
|
||||
}
|
||||
|
||||
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), None)
|
||||
}
|
||||
148
src/post_manager.rs
Normal file
148
src/post_manager.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use crate::git_tracker::{get_git_version, GitVersion};
|
||||
use chrono::{DateTime, Utc};
|
||||
use pulldown_cmark::{html, Options, Parser};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Post {
|
||||
pub name: String,
|
||||
pub filename: String,
|
||||
pub markdown_content: String,
|
||||
pub html_content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct PostManager {
|
||||
git_dir: PathBuf,
|
||||
posts_dir: PathBuf,
|
||||
posts: HashMap<String, Post>,
|
||||
last_git_version: Option<GitVersion>,
|
||||
}
|
||||
|
||||
impl PostManager {
|
||||
pub fn new(root_dir: &str) -> Result<Self, String> {
|
||||
let git_dir = PathBuf::from(root_dir);
|
||||
let posts_dir = git_dir.join("posts");
|
||||
|
||||
// Create posts directory if it doesn't exist
|
||||
if !posts_dir.exists() {
|
||||
fs::create_dir_all(&posts_dir)
|
||||
.map_err(|e| format!("Failed to create posts directory: {}", e))?;
|
||||
}
|
||||
|
||||
let mut manager = PostManager {
|
||||
git_dir,
|
||||
posts_dir,
|
||||
posts: HashMap::new(),
|
||||
last_git_version: None,
|
||||
};
|
||||
|
||||
manager.refresh_posts()?;
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub fn refresh_if_needed(&mut self) -> Result<bool, String> {
|
||||
let current_version = get_git_version(self.git_dir.to_str().unwrap())?;
|
||||
|
||||
if self.last_git_version.as_ref() != Some(¤t_version) {
|
||||
println!(
|
||||
"Git version changed: {} -> {}",
|
||||
self.last_git_version
|
||||
.as_ref()
|
||||
.map(|v| v.key())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
current_version.key()
|
||||
);
|
||||
self.refresh_posts()?;
|
||||
self.last_git_version = Some(current_version);
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_posts(&mut self) -> Result<(), String> {
|
||||
self.posts.clear();
|
||||
|
||||
let entries = fs::read_dir(&self.posts_dir)
|
||||
.map_err(|e| format!("Failed to read posts directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| "Invalid filename".to_string())?
|
||||
.to_string();
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| "Invalid file stem".to_string())?
|
||||
.to_string();
|
||||
|
||||
let markdown_content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read post file: {}", e))?;
|
||||
|
||||
let html_content = markdown_to_html(&markdown_content);
|
||||
|
||||
// Mock creation date for now
|
||||
let metadata = fs::metadata(&path)
|
||||
.map_err(|e| format!("Failed to read file metadata: {}", e))?;
|
||||
let created_at = metadata
|
||||
.created()
|
||||
.or_else(|_| metadata.modified())
|
||||
.map(|t| DateTime::<Utc>::from(t))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
|
||||
let post = Post {
|
||||
name: name.clone(),
|
||||
filename,
|
||||
markdown_content,
|
||||
html_content,
|
||||
created_at,
|
||||
};
|
||||
|
||||
self.posts.insert(name, post);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Loaded {} posts", self.posts.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_post(&self, name: &str) -> Option<&Post> {
|
||||
self.posts.get(name)
|
||||
}
|
||||
|
||||
pub fn get_all_posts(&self) -> Vec<&Post> {
|
||||
let mut posts: Vec<&Post> = self.posts.values().collect();
|
||||
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
posts
|
||||
}
|
||||
|
||||
pub fn get_posts_limited(&self, limit: usize) -> Vec<&Post> {
|
||||
let mut posts = self.get_all_posts();
|
||||
posts.truncate(limit);
|
||||
posts
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown_to_html(markdown: &str) -> 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 parser = Parser::new_ext(markdown, options);
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
html_output
|
||||
}
|
||||
140
src/template_engine.rs
Normal file
140
src/template_engine.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::post_manager::PostManager;
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
|
||||
pub fn render_template(
|
||||
template: &str,
|
||||
post_manager: &PostManager,
|
||||
current_post: Option<&str>,
|
||||
limit_override: Option<usize>,
|
||||
) -> Result<String, String> {
|
||||
let mut result = template.to_string();
|
||||
|
||||
// Process includes first
|
||||
result = process_includes(&result)?;
|
||||
|
||||
// Process post-html tag
|
||||
if let Some(post_name) = current_post {
|
||||
result = process_post_html(&result, post_manager, post_name)?;
|
||||
}
|
||||
|
||||
// Process post-list tag
|
||||
result = process_post_list(&result, post_manager, limit_override)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn process_includes(template: &str) -> Result<String, String> {
|
||||
let include_regex = Regex::new(r#"\$<include\s+src="([^"]+)"\s*/>"#).unwrap();
|
||||
let mut result = template.to_string();
|
||||
let mut iteration = 0;
|
||||
const MAX_ITERATIONS: usize = 10;
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
if iteration > MAX_ITERATIONS {
|
||||
return Err("Too many nested includes (max 10)".to_string());
|
||||
}
|
||||
|
||||
let result_copy = result.clone();
|
||||
let captures: Vec<_> = include_regex.captures_iter(&result_copy).collect();
|
||||
|
||||
if captures.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for cap in captures {
|
||||
let full_match = cap.get(0).unwrap().as_str();
|
||||
let src = cap.get(1).unwrap().as_str();
|
||||
|
||||
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))?;
|
||||
|
||||
result = result.replace(full_match, &content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn process_post_html(
|
||||
template: &str,
|
||||
post_manager: &PostManager,
|
||||
post_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let post_html_regex = Regex::new(r"\$<post-html\s*/?>").unwrap();
|
||||
|
||||
let post = post_manager
|
||||
.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,
|
||||
limit_override: Option<usize>,
|
||||
) -> 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_override.or(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);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_post_list_regex() {
|
||||
let regex = Regex::new(r#"\$<post-list(?:\s+limit=(\d+))?\s*/>"#).unwrap();
|
||||
|
||||
assert!(regex.is_match("$<post-list/>"));
|
||||
assert!(regex.is_match("$<post-list />"));
|
||||
assert!(regex.is_match("$<post-list limit=50/>"));
|
||||
assert!(regex.is_match("$<post-list limit=50 />"));
|
||||
|
||||
let cap = regex.captures("$<post-list limit=50/>").unwrap();
|
||||
assert_eq!(cap.get(1).unwrap().as_str(), "50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_include_regex() {
|
||||
let regex = Regex::new(r#"\$<include\s+src="([^"]+)"\s*/>"#).unwrap();
|
||||
|
||||
assert!(regex.is_match(r#"$<include src="header.html"/>"#));
|
||||
assert!(regex.is_match(r#"$<include src="header.html" />"#));
|
||||
|
||||
let cap = regex.captures(r#"$<include src="header.html"/>"#).unwrap();
|
||||
assert_eq!(cap.get(1).unwrap().as_str(), "header.html");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user