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

9
.cargo/config.toml Normal file
View File

@@ -0,0 +1,9 @@
[profile.small]
inherits = "release"
opt-level = "z"
codegen-units = 1
lto = true
[profile.extra-small]
inherits = "small"
panic = "abort"

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
/.nvim.lua
/.nvim.workspace.lua
/blog-server

1126
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "blog-server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["fs"] }
pulldown-cmark = "0.12"
serde = { version = "1.0", features = ["derive"] }
chrono = "0.4"
once_cell = "1.19"
clap = { version = "4.5", features = ["derive"] }

153
lua/lemon.lua Normal file
View File

@@ -0,0 +1,153 @@
Lemon = {
ws = {},
ws_file = '.nvim.workspace.lua',
term_buf = nil,
term_win_cmd = 'belowright 12split',
}
function LoadWorkspace()
-- Load persistent configuration from .workspace.lua
local loaded, workspace = pcall(dofile, Lemon.ws_file)
if not loaded then return nil end
return workspace
end
function WriteWorkspace()
-- A very minimal serializer for workspace configuration
local s = { l = "", ls = {}, i = "" }
local function w(v) s.l = s.l .. v end
local function nl()
s.ls[#s.ls + 1] = s.l; s.l = s.i;
end
local function wv(v)
local t = type(v)
if t == 'table' then
w('{'); local pi = s.i; s.i = s.i .. " "
for k1, v1 in pairs(v) do
nl(); w('['); wv(k1); w('] = '); wv(v1); w(',')
end
s.i = pi; nl(); w('}');
elseif t == 'number' then
w(tostring(v))
elseif t == 'string' then
w('"' .. v .. '"')
else
w(tostring(v))
end
end
-- Write the workspace file
w("return "); wv(Lemon.ws); nl()
vim.fn.writefile(s.ls, Lemon.ws_file)
end
-- Loads the workspace from the file, or return the default
---@param default table
---@return table
function InitWorkspace(default)
Lemon.ws = LoadWorkspace()
if Lemon.ws == nil then
Lemon.ws = default
end
return Lemon.ws
end
function TermShow()
local info = GetTermInfo()
if info == nil then
-- Create new terminal buffer
vim.cmd(Lemon.term_win_cmd)
vim.cmd('terminal')
Lemon.term_buf = vim.api.nvim_get_current_buf()
-- Mark buffer so we can identify it later
vim.api.nvim_buf_set_var(Lemon.term_buf, 'lemon_terminal', true)
info = GetTermInfo()
elseif info.win == nil then
-- Buffer exists but not visible, open it
vim.cmd(Lemon.term_win_cmd)
vim.api.nvim_win_set_buf(0, Lemon.term_buf)
else
-- Window is visible, switch to it
vim.api.nvim_set_current_win(info.win)
end
return info
end
-- Find or create persistent terminal buffer, open window, and run command
function TermRun(cmd)
local info = TermShow()
-- Send command to terminal
vim.fn.chansend(info.job_id, '\021' .. cmd .. '\n')
vim.fn.feedkeys("G", "n")
end
-- Get terminal buffer and job_id if valid, returns {buf, job_id, win}
-- win is nil if terminal is not currently visible
function GetTermInfo()
if Lemon.term_buf == nil or not vim.api.nvim_buf_is_valid(Lemon.term_buf) then
return nil
end
local job_id = vim.api.nvim_buf_get_var(Lemon.term_buf, 'terminal_job_id')
-- Find window showing the terminal buffer
local win = nil
for _, w in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(w) == Lemon.term_buf then
win = w
break
end
end
return { buf = Lemon.term_buf, job_id = job_id, win = win }
end
-- Compatibility wrapper - returns window ID if terminal is visible
function SwitchToExistingTerm()
local info = GetTermInfo()
return info and info.win or nil
end
-- Runs the make command and runs the callback when it completes
function MakeAnd(run_callback)
-- Create a one-time autocmd that fires when make completes
local group = vim.api.nvim_create_augroup('MakeAnd', { clear = false })
vim.api.nvim_create_autocmd('QuickFixCmdPost', {
group = group,
pattern = 'make',
once = true,
callback = function()
local qf_list = vim.fn.getqflist()
local has_errors = false
for _, item in ipairs(qf_list) do
if item.valid == 1 then
has_errors = true
break
end
end
vim.schedule(function()
if not has_errors then
run_callback()
else
vim.api.nvim_echo({ { "Build failed", "ErrorMsg" } }, false, {})
end
end)
end
})
vim.cmd('silent make')
end
function TabCurrent()
return vim.fn.tabpagenr()
end
function TabSwitch(tab)
vim.cmd('tabnext ' .. tab)
end

106
posts/blog-server.md Normal file
View File

@@ -0,0 +1,106 @@
# Blog setup with markdown, rust & git
Hey everyone. This is the first post I write for the blog I'm starting, which is Coincidentally about how I've structured the software surrounding my blog.
First off, I've been wanting to do a lot recently:
- Moving away from Windows to using Linux and MacOS for development and gaming
- Switching from VSCode/Cursor to neovim
- Switching from working for an employer to becoming a solo dev/freelancer
- Dedicate time to solo game development
Because of this, I figured it would be a good time to set up a blog, both for documenting whatever I'm working on and to get the word out about my work.
## The blog design
I wanted to have a setup that generates static html from something like markdown. Then recently I stumbled across this [https://gaultier.github.io/blog/making_my_static_blog_generator_11_times_faster.html](https://gaultier.github.io/blog/making_my_static_blog_generator_11_times_faster.html)
I decided to do something similar, however I used rust as it has some popular existing libraries for web servers and parsing markdown. The reason for writing an application to host the blog is that I wanted to have it automatically respond to a git webhook which would automatically pull the latest git repo and then rebuild the articles from their markdown files.
## Parsing the git output
As mentioned in [this article](https://gaultier.github.io/blog/making_my_static_blog_generator_11_times_faster.html) they use the git log command to retrieve the blog files, which I decided to also use. I found out there is a way to retreive the output from git in the following predictable format:
```
c584d39 - 1761735775 - Update server post and fix post titles
:100644 100644 d5e614a 678eb09 M posts/blog-server.md
9d5f86a - 1761415068 - Template
:000000 100644 0000000 d5e614a A posts/blog-server.md
```
Retrieved with the following command line: `git log --raw --no-merges --pretty="%h - %ad - %s" --date=unix -- posts`
Where the first entry will always be a commit hash followed by unix time stamp and then the commit name. After that it will list files affected by this commit, prefixed with a colon (:).
Then we can simply parse this by reading line by line and attributing the following file changes to the commit that was above it. Then I use the first encounter of a given file to determine the creation date, and the last encounter to determine the update date.
### (Bonus) Parsing the git version
There's a nice trick to retrieve the current git branch and commit hash by just reading 2 files, which I grabbed from one of my CMake scripts that uses it to inject the git version into my code on every compile. It's super fast since it doesn't actually run any git commands to retrieve this information.
The process is, read .git/HEAD, which will either contain just the hash, or something like `ref: refs/heads/main`.\
In the later case, you can just read from .git/refs/heads/main which will then contain your git hash.
This version is inserted at the bottom of the page so I can tell which version it's at.
## HTML
Of course there is also some styling and markup required for the shell, like the navigation bar and footers.\
I decided to go with a simple list of html templates that get included by the application and scan it for tags to inject certain magic values, like the list of posts or timestamps.
For example, here is what the page you're looking at looks like:
```html
$<include src="header.html"/>
<article>
$<post-html/>
</article>
<p><a href="/">← Back to home</a></p>
$<include src="footer.html"/>
```
Which can be parsed very quickly by a simple scanner that triggers on $< and then reads the key and map of `<String,String>` parameters.
This allows me to just write template code like this:
```html
<title>$<title default="Guus' blog" pre="Guus - "></title>
```
and process it in rust like this:
```rs
"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())
}
}
```
All the HTML code that has been generated like this is then cached until the next the blog update is triggered by my pushing a new post to git.
## Styling
For the styling, I added some minimal CSS to center the contents, limit the width and change the font sizes.\
I wanted to keep it simple so the layout works on both a desktop browser, and on mobile phone screens. This also allows you to dock the window to a side of your screen or second vertical monitor in case you wanted to reference it for some code which I find useful.
## Conclusion
I like this design as it's easy to deploy locally for previewing, and adding new posts or making edits is as simple as `git commit && git push`. I can keep posts I'm working on in separate branches and progressively work on them like that.
If you're interested in the source code, or would like to use it for yourself feel free to check it out [here](https://git.bakje.coffee/guus/blog).

136
project_templates/.nvim.lua Normal file
View File

@@ -0,0 +1,136 @@
require("lua/lemon")
local def_workspace = { args = {}, build_type = "debug", binary = "blog-server" }
local workspace = InitWorkspace(def_workspace)
local build_folder
local bin_target
local bin_name
local function updateBuildEnv()
build_folder = './target/' .. workspace.build_type
bin_target = workspace.binary
bin_name = build_folder .. '/' .. bin_target
-- The run (F6) arguments
vim.opt.makeprg = "cargo build"
vim.g.cargo_makeprg_params = 'build'
if workspace.build_type == "release" then
vim.opt.makeprg = vim.opt.makeprg .. " --release"
vim.g.cargo_makeprg_params = vim.g.cargo_makeprg_params .. " --release"
end
-- Rust compiler error format
vim.opt.errorformat = {
-- Main error/warning line with file:line:col format
'%E%>error%m,' .. -- Start of error block
'%W%>warning: %m,' .. -- Start of warning block
'%-G%>help: %m,' .. -- Ignore help lines
'%-G%>note: %m,' .. -- Ignore note lines
'%C%> --> %f:%l:%c,' .. -- Continuation: file location
'%Z%>%p^%m,' .. -- End: column pointer (^^^)
'%C%>%s%#|%.%#,' .. -- Continuation: context lines with |
'%C%>%s%#%m,' .. -- Continuation: other context
'%-G%.%#' -- Ignore everything else
}
end
updateBuildEnv()
-- Prevent Vim's built-in rust ftplugin from loading the cargo compiler
vim.api.nvim_create_autocmd("FileType", {
pattern = "rust",
callback = function()
vim.b.current_compiler = 'custom'
end,
})
-- nvim-dap configuration
local dap_ok, dap = pcall(require, "dap")
local dap_def_cfg
local dap_configs
-- Update args for both run and debug configs
local function updateArgs(args)
workspace.args = args
if dap_configs ~= nil then dap_configs[1].args = args end
WriteWorkspace()
end
-- The Configure command
vim.api.nvim_create_user_command("Configure", function(a)
local args = {}
local bt = "debug"
if #a.args > 0 then bt = a.args end
workspace.build_type = bt
updateBuildEnv()
WriteWorkspace()
end, { nargs = '?', desc = "Update run/debug arguments" })
vim.api.nvim_create_user_command("Args", function(a) updateArgs(a.fargs) end,
{ nargs = '*', desc = "Update run/debug arguments" })
if dap_ok then
local rust_path = vim.fn.system("rustc --print sysroot") -- We need to query this to get the sysroot
rust_path = string.sub(rust_path, 1, #rust_path - 1) -- trim trailing newline
local lldb_init = {
"command script import " .. rust_path .. "/lib/rustlib/etc/lldb_lookup.py",
"command source " .. rust_path .. "/lib/rustlib/etc/lldb_commands",
}
dap_configs = {
{
name = 'default',
type = 'codelldb',
request = 'launch',
program = bin_name,
args = workspace.args,
cwd = '${workspaceFolder}',
stopOnEntry = false,
initCommands = lldb_init
}
}
dap_def_cfg = dap_configs[1]
dap.providers.configs["project"] = function()
return dap_configs
end
-- DebugArgs to set debugger arguments and run immediately
vim.api.nvim_create_user_command("DebugArgs", function(a)
updateArgs(a.fargs)
dap.run(dap_configs[1])
end, { nargs = '*', desc = "Starts debugging with specified arguments" })
end
local r = function()
MakeAnd(function()
TermRun(bin_name .. " " .. table.concat(workspace.args, " "))
end)
end
-- RunArgs sets the run arguments that F6 uses and reruns immediately
vim.api.nvim_create_user_command("RunArgs", function(a)
updateArgs(a.fargs)
r()
end, { nargs = '*', desc = "Starts debugging with specified arguments" })
-- F6 to run the application
vim.keymap.set('n', '<F6>', r)
vim.keymap.set('n', '<F18>', function()
local info = GetTermInfo()
if info then
-- Send interrupt to terminal without switching
vim.fn.chansend(info.job_id, '\003')
-- Close window if it's open
vim.print("Stopped program")
else
vim.print("No terminal buffer found")
end
end)
if dap_ok then
-- Shift-F5 to launch default config
vim.keymap.set('n', '<F17>', function()
MakeAnd(function()
dap.run(dap_def_cfg)
end)
end)
end

9
scripts/deploy Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
ROOT=$(dirname $0)/..
pushd $ROOT
set -ex
cargo build --profile extra-small
cp -f target/extra-small/blog-server ./

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());
}
}

8
templates/footer.html Normal file
View File

@@ -0,0 +1,8 @@
<footer style="margin-top: 50px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; text-align: center;">
<p>
Guus Waals - updated on $<updated/> </br>
$<version/>
</p>
</footer>
</body>
</html>

109
templates/header.html Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$<title default="Guus' blog" pre="Guus - "></title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
nav {
border-bottom: 2px solid #333;
padding-bottom: 10px;
margin-bottom: 30px;
}
nav span {
font-size: 1.2em;
font-weight: bold;
margin-right: 20px;
}
nav a {
margin-right: 20px;
text-decoration: none;
color: #0066cc;
}
nav a:hover {
text-decoration: underline;
}
.post-list {
list-style: none;
padding: 0;
}
.post-list li {
margin-bottom: 10px;
padding: 10px;
border-left: 3px solid #0066cc;
padding-left: 15px;
}
.post-list a {
text-decoration: none;
color: #333;
font-weight: bold;
}
.post-list a:hover {
color: #0066cc;
}
.date {
color: #666;
font-size: 0.9em;
float: right;
}
article {
margin-top: 30px;
}
h1, h2, h3 {
color: #222;
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
}
pre {
background: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
pre code {
padding: 0;
}
.social-link {
float: right;
display: inline-block;
width: 24px;
height: 24px;
vertical-align: middle;
margin-left: 0px;
}
.social-link svg {
fill: #333;
transition: fill 0.2s;
}
.social-link:hover svg {
fill: #0066cc;
}
</style>
</head>
<body>
<nav>
<span>(dis) gus' things</span>
<a href="/">Home</a>
<a href="/all">All Posts</a>
<a href="https://bsky.app/profile/guusww.bsky.social" class="social-link" target="_blank" aria-label="Follow on Bluesky">
<svg viewBox="0 0 568 501" width="24" height="24" aria-hidden="true">
<path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/>
</svg>
</a>
<a href="https://github.com/guusw" class="social-link" target="_blank" aria-label="View source on GitHub">
<svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
</nav>

4
templates/index.html Normal file
View File

@@ -0,0 +1,4 @@
$<include src="header.html"/>
<h1>Recent Posts</h1>
$<post-list limit="10"/>
$<include src="footer.html"/>

4
templates/page_all.html Normal file
View File

@@ -0,0 +1,4 @@
$<include src="header.html"/>
<h1>All Posts</h1>
$<post-list/>
$<include src="footer.html"/>

6
templates/post.html Normal file
View File

@@ -0,0 +1,6 @@
$<include src="header.html"/>
<article>
$<post-html/>
</article>
<p><a href="/">← Back to home</a></p>
$<include src="footer.html"/>