Update server post and fix post titles

This commit is contained in:
2025-10-29 19:02:55 +08:00
parent 185d9d4d63
commit c584d39607
8 changed files with 493 additions and 61 deletions

127
Cargo.lock generated
View File

@@ -11,6 +11,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -101,6 +151,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
"clap",
"once_cell", "once_cell",
"pulldown-cmark", "pulldown-cmark",
"serde", "serde",
@@ -150,6 +201,52 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "clap"
version = "4.5.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -225,6 +322,12 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "http" name = "http"
version = "1.3.1" version = "1.3.1"
@@ -338,6 +441,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@@ -429,6 +538,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -631,6 +746,12 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.108" version = "2.0.108"
@@ -791,6 +912,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"

View File

@@ -12,3 +12,4 @@ pulldown-cmark = "0.12"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
chrono = "0.4" chrono = "0.4"
once_cell = "1.19" once_cell = "1.19"
clap = { version = "4.5", features = ["derive"] }

152
lua/lemon.lua Normal file
View File

@@ -0,0 +1,152 @@
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.workspace); 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

View File

@@ -1,10 +1,79 @@
# Blog setup with markdown, rust & git # Blog setup with markdown, rust & git
I've recently went trough a lot of changes in my career such as starting freelancing, setting up my own projects and wanting to take a serious attempt at solo game-dev. With this I figured it'd be nice to have a dev blog of sorts to keep track of progress and maybe share some interesting research or developments. 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 ## 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 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.
## 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).
spall checkaff

View File

@@ -5,15 +5,24 @@ mod template_engine;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse}, response::{Html, IntoResponse, Response},
routing::get, routing::get,
Router, Router,
}; };
use clap::Parser;
use std::sync::Arc; use std::sync::Arc;
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tower_http::services::ServeDir; 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)] #[derive(Clone)]
struct AppState { struct AppState {
post_manager: Arc<RwLock<post_manager::PostManager>>, post_manager: Arc<RwLock<post_manager::PostManager>>,
@@ -21,14 +30,20 @@ struct AppState {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let args = Args::parse();
let post_manager = Arc::new(RwLock::new( let post_manager = Arc::new(RwLock::new(
post_manager::PostManager::new(".").expect("Failed to initialize post manager"), post_manager::PostManager::new(".", args.no_cache).expect("Failed to initialize post manager"),
)); ));
let app_state = AppState { let app_state = AppState {
post_manager: post_manager.clone(), post_manager: post_manager.clone(),
}; };
if args.no_cache {
println!("Running with caching disabled");
}
let p_router = Router::new() let p_router = Router::new()
.route("/:post_name", get(post_handler)) .route("/:post_name", get(post_handler))
.fallback_service(ServeDir::new("posts")); .fallback_service(ServeDir::new("posts"));
@@ -51,14 +66,14 @@ async fn main() {
.expect("Failed to start server"); .expect("Failed to start server");
} }
async fn index_handler(State(state): State<AppState>) -> impl IntoResponse { async fn index_handler(State(state): State<AppState>) -> Response {
match render_template("index.html", &state).await { match render_template("index.html", &state).await {
Ok(html) => Html(html).into_response(), Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).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 { async fn all_handler(State(state): State<AppState>, Path(page): Path<String>) -> Response {
if page.contains("..") { if page.contains("..") {
return (StatusCode::NOT_FOUND, "Invalid path").into_response(); return (StatusCode::NOT_FOUND, "Invalid path").into_response();
} }
@@ -71,13 +86,13 @@ async fn all_handler(State(state): State<AppState>, Path(page): Path<String>) ->
async fn post_handler( async fn post_handler(
State(state): State<AppState>, State(state): State<AppState>,
Path(post_name): Path<String>, Path(post_name): Path<String>,
) -> impl IntoResponse { ) -> Response {
if post_name.contains("..") { if post_name.contains("..") {
return (StatusCode::NOT_FOUND, "Invalid path").into_response(); return (StatusCode::NOT_FOUND, "Invalid path").into_response();
} }
let manager = state.post_manager.read().await; let manager = state.post_manager.read().await;
match manager.get_post(&post_name) { match manager.get_post(&post_name).await {
Some(_post) => { Some(_post) => {
drop(manager); drop(manager);
match render_post_template(&state, post_name).await { match render_post_template(&state, post_name).await {
@@ -148,7 +163,7 @@ async fn render_template(template_name: &str, state: &AppState) -> Result<String
.map_err(|e| format!("Failed to read template: {}", e))?; .map_err(|e| format!("Failed to read template: {}", e))?;
let manager = state.post_manager.read().await; let manager = state.post_manager.read().await;
template_engine::render_template(&template_content, &*manager, None) template_engine::render_template(&template_content, &*manager, None).await
} }
async fn render_post_template(state: &AppState, post_name: String) -> Result<String, String> { async fn render_post_template(state: &AppState, post_name: String) -> Result<String, String> {
@@ -156,5 +171,5 @@ async fn render_post_template(state: &AppState, post_name: String) -> Result<Str
.map_err(|e| format!("Failed to read post template: {}", e))?; .map_err(|e| format!("Failed to read post template: {}", e))?;
let manager = state.post_manager.read().await; let manager = state.post_manager.read().await;
template_engine::render_template(&template_content, &*manager, Some(&post_name)) template_engine::render_template(&template_content, &*manager, Some(&post_name)).await
} }

View File

@@ -1,9 +1,11 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use pulldown_cmark::{html, Event, HeadingLevel, Options, Parser, Tag}; use pulldown_cmark::{html, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Post { pub struct Post {
@@ -30,11 +32,12 @@ struct Entry {
pub struct PostManager { pub struct PostManager {
root_dir: PathBuf, root_dir: PathBuf,
posts_dir_rel: String, posts_dir_rel: String,
posts: HashMap<String, Post>, posts: HashMap<String, Arc<RwLock<Post>>>,
no_cache: bool,
} }
impl PostManager { impl PostManager {
pub fn new(root_dir: &str) -> Result<Self, String> { pub fn new(root_dir: &str, no_cache: bool) -> Result<Self, String> {
let root_dir = PathBuf::from(root_dir); let root_dir = PathBuf::from(root_dir);
let posts_dir_rel = "posts"; let posts_dir_rel = "posts";
@@ -42,6 +45,7 @@ impl PostManager {
root_dir, root_dir,
posts_dir_rel: posts_dir_rel.to_string(), posts_dir_rel: posts_dir_rel.to_string(),
posts: HashMap::new(), posts: HashMap::new(),
no_cache,
}; };
manager.refresh_posts()?; manager.refresh_posts()?;
@@ -155,37 +159,68 @@ impl PostManager {
modified_at: entry.modified_at, modified_at: entry.modified_at,
}; };
eprintln!("Loaded post: {} ({})", name, entry.created_at); eprintln!("Loaded post: {} ({})", name, entry.created_at);
self.posts.insert(name, post); self.posts.insert(name, Arc::new(RwLock::new(post)));
} }
println!("Loaded {} posts", self.posts.len()); println!("Loaded {} posts", self.posts.len());
Ok(()) Ok(())
} }
pub fn get_post(&self, name: &str) -> Option<&Post> { pub async fn get_post(&self, name: &str) -> Option<Post> {
self.posts.get(name) 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 // Get all posts, sorted by creation date
pub fn get_all_posts(&self) -> Vec<&Post> { pub async fn get_all_posts(&self) -> Vec<Post> {
let mut posts: Vec<&Post> = self.posts.values().collect(); 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.sort_by(|a, b| b.created_at.cmp(&a.created_at));
posts posts
} }
// Get the timstamp of when the blog was most recently updated // Get the timstamp of when the blog was most recently updated
// derived from latest post update // derived from latest post update
pub fn get_update_timestamp(&self) -> Result<DateTime<Utc>, String> { pub async fn get_update_timestamp(&self) -> Result<DateTime<Utc>, String> {
let mut posts: Vec<&Post> = self.posts.values().collect(); let posts = self.get_all_posts().await;
posts.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); let mut posts_sorted: Vec<&Post> = posts.iter().collect();
Ok(posts posts_sorted.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(posts_sorted
.first() .first()
.ok_or("No posts found".to_string())? .ok_or("No posts found".to_string())?
.created_at) .created_at)
} }
pub fn get_posts_limited(&self, limit: usize) -> Vec<&Post> { pub async fn get_posts_limited(&self, limit: usize) -> Vec<Post> {
let mut posts = self.get_all_posts(); let mut posts = self.get_all_posts().await;
posts.truncate(limit); posts.truncate(limit);
posts posts
} }
@@ -193,20 +228,20 @@ impl PostManager {
fn markdown_title(markdown: &str) -> Option<String> { fn markdown_title(markdown: &str) -> Option<String> {
let parser = Parser::new(markdown); let parser = Parser::new(markdown);
let mut in_tag = false;
for event in parser { for event in parser {
if let Event::Start(tag) = event { match event {
if let Tag::Heading { Event::Start(Tag::Heading {
level, level: HeadingLevel::H1,
id,
.. ..
} = tag }) => in_tag = true,
{ Event::End(TagEnd::Heading(HeadingLevel::H1)) => in_tag = false,
if level == HeadingLevel::H1 { Event::Text(txt) => {
if let Some(str) = id { if in_tag {
return Some(str.to_string()); return Some(txt.to_string());
}
} }
} }
_ => {},
} }
} }
None None

View File

@@ -1,17 +1,28 @@
use crate::post_manager::PostManager;
use crate::git_tracker::get_git_version; use crate::git_tracker::get_git_version;
use crate::post_manager::PostManager;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::future::Future;
use std::pin::Pin;
pub fn render_template( pub async fn render_template(
template: &str, template: &str,
post_manager: &PostManager, post_manager: &PostManager,
current_post: Option<&str>, current_post: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
parse_template(template, post_manager, current_post, 0) parse_template(template, post_manager, current_post, 0).await
} }
fn parse_template( 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, template: &str,
post_manager: &PostManager, post_manager: &PostManager,
current_post: Option<&str>, current_post: Option<&str>,
@@ -32,7 +43,8 @@ fn parse_template(
// Try to parse tag // Try to parse tag
if let Some((tag_name, attrs, end_pos)) = parse_tag(&chars, i) { if let Some((tag_name, attrs, end_pos)) = parse_tag(&chars, i) {
// Process tag immediately based on type // Process tag immediately based on type
let content = process_tag(&tag_name, &attrs, post_manager, current_post, depth)?; let content =
process_tag(&tag_name, &attrs, post_manager, current_post, depth).await?;
result.push_str(&content); result.push_str(&content);
i = end_pos; i = end_pos;
continue; continue;
@@ -140,7 +152,7 @@ fn parse_tag(chars: &[char], start: usize) -> Option<(String, HashMap<String, St
} }
} }
fn process_tag( async fn process_tag(
tag_name: &str, tag_name: &str,
attrs: &HashMap<String, String>, attrs: &HashMap<String, String>,
post_manager: &PostManager, post_manager: &PostManager,
@@ -149,7 +161,8 @@ fn process_tag(
) -> Result<String, String> { ) -> Result<String, String> {
match tag_name { match tag_name {
"include" => { "include" => {
let src = attrs.get("src") let src = attrs
.get("src")
.ok_or_else(|| "include tag missing 'src' attribute".to_string())?; .ok_or_else(|| "include tag missing 'src' attribute".to_string())?;
let include_path = format!("templates/{}", src); let include_path = format!("templates/{}", src);
@@ -157,7 +170,7 @@ fn process_tag(
.map_err(|e| format!("Failed to read include file '{}': {}", src, e))?; .map_err(|e| format!("Failed to read include file '{}': {}", src, e))?;
// Recursively parse the included content // Recursively parse the included content
parse_template(&content, post_manager, current_post, depth + 1) parse_template(&content, post_manager, current_post, depth + 1).await
} }
"post-html" => { "post-html" => {
@@ -166,28 +179,43 @@ fn process_tag(
let post = post_manager let post = post_manager
.get_post(post_name) .get_post(post_name)
.await
.ok_or_else(|| format!("Post '{}' not found", post_name))?; .ok_or_else(|| format!("Post '{}' not found", post_name))?;
Ok(post.html_content.clone()) Ok(post.html_content.clone())
} }
"title" => { "title" => {
let post_name = current_post if let Some(post_name) = current_post {
.ok_or_else(|| "title 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))?;
let post = post_manager let none: String = "".to_string();
.get_post(post_name) let pre = attrs.get("pre").unwrap_or(&none);
.ok_or_else(|| format!("Post '{}' not found", post_name))?; let postfix = attrs.get("post").unwrap_or(&none);
Ok(post.title.to_string()) 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" => { "updated" => {
// Insert the last updated time of the current post (or most recent post) // Insert the last updated time of the current post (or most recent post)
let post_ts = if let Some(p) = current_post { let post_ts = if let Some(p) = current_post {
post_manager.get_post(p).ok_or_else(|| format!("Post '{}' not found", p))?.modified_at post_manager
.get_post(p)
.await
.ok_or_else(|| format!("Post '{}' not found", p))?
.modified_at
} else { } else {
post_manager.get_update_timestamp()? post_manager.get_update_timestamp().await?
}; };
Ok(post_ts.format("%Y-%m-%d").to_string()) Ok(post_ts.format("%Y-%m-%d").to_string())
@@ -200,13 +228,12 @@ fn process_tag(
} }
"post-list" => { "post-list" => {
let limit = attrs.get("limit") let limit = attrs.get("limit").and_then(|s| s.parse::<usize>().ok());
.and_then(|s| s.parse::<usize>().ok());
let posts = if let Some(l) = limit { let posts = if let Some(l) = limit {
post_manager.get_posts_limited(l) post_manager.get_posts_limited(l).await
} else { } else {
post_manager.get_all_posts() post_manager.get_all_posts().await
}; };
let mut list_html = String::from("<ul class=\"post-list\">\n"); let mut list_html = String::from("<ul class=\"post-list\">\n");
@@ -214,7 +241,7 @@ fn process_tag(
list_html.push_str(&format!( list_html.push_str(&format!(
" <li><a href=\"/p/{}\">{}</a> <span class=\"date\">{}</span></li>\n", " <li><a href=\"/p/{}\">{}</a> <span class=\"date\">{}</span></li>\n",
post.name, post.name,
post.name, post.title,
post.created_at.format("%Y-%m-%d") post.created_at.format("%Y-%m-%d")
)); ));
} }
@@ -223,7 +250,7 @@ fn process_tag(
Ok(list_html) Ok(list_html)
} }
_ => Err(format!("Unknown tag: {}", tag_name)) _ => Err(format!("Unknown tag: {}", tag_name)),
} }
} }

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Blog</title> <title>$<title default="Guus' blog" pre="Guus - "></title>
<style> <style>
body { body {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
@@ -74,18 +74,19 @@
pre code { pre code {
padding: 0; padding: 0;
} }
.github-link { .social-link {
float: right; float: right;
display: inline-block; display: inline-block;
width: 24px; width: 24px;
height: 24px; height: 24px;
vertical-align: middle; vertical-align: middle;
margin-left: 0px;
} }
.github-link svg { .social-link svg {
fill: #333; fill: #333;
transition: fill 0.2s; transition: fill 0.2s;
} }
.github-link:hover svg { .social-link:hover svg {
fill: #0066cc; fill: #0066cc;
} }
</style> </style>
@@ -95,7 +96,12 @@
<span>(dis) gus' things</span> <span>(dis) gus' things</span>
<a href="/">Home</a> <a href="/">Home</a>
<a href="/all">All Posts</a> <a href="/all">All Posts</a>
<a href="https://github.com/guusw" class="github-link" target="_blank" aria-label="View source on Git"> <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"> <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"/> <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> </svg>