Compare commits

...

3 Commits

Author SHA1 Message Date
c584d39607 Update server post and fix post titles 2025-10-29 19:02:55 +08:00
185d9d4d63 Add post title 2025-10-29 17:15:56 +08:00
9d5f86ad46 Template 2025-10-26 01:57:48 +08:00
9 changed files with 563 additions and 122 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

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

@@ -0,0 +1,79 @@
# 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.
## 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).

View File

@@ -1,9 +1,7 @@
-- Load persistent configuration from .workspace.lua require("lua/lemon")
local ws_file = "./.nvim.workspace.lua"
local loaded, workspace = pcall(dofile, ws_file) local def_workspace = { args = {}, build_type = "debug", binary = "blog-server" }
if not loaded then local workspace = InitWorkspace(def_workspace)
workspace = { args = {}, build_type = "debug", binary = "blog-server" }
end
local build_folder local build_folder
local bin_target local bin_target
@@ -47,35 +45,6 @@ vim.api.nvim_create_autocmd("FileType", {
end, end,
}) })
local 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(workspace); nl()
vim.fn.writefile(s.ls, ws_file)
end
-- nvim-dap configuration -- nvim-dap configuration
local dap_ok, dap = pcall(require, "dap") local dap_ok, dap = pcall(require, "dap")
local dap_def_cfg local dap_def_cfg
@@ -85,22 +54,7 @@ local dap_configs
local function updateArgs(args) local function updateArgs(args)
workspace.args = args workspace.args = args
if dap_configs ~= nil then dap_configs[1].args = args end if dap_configs ~= nil then dap_configs[1].args = args end
writeWorkspace() WriteWorkspace()
end
-- Find terminal tab or create new one, then run command
local function TermRun(cmd)
local found = false
for i = 1, vim.fn.tabpagenr('$') do
vim.cmd('tabnext ' .. i)
if vim.bo.buftype == 'terminal' then
found = true
break
end
end
if not found then vim.cmd('tabnew | terminal') end
vim.fn.chansend(vim.b.terminal_job_id, '' .. cmd .. '\n')
vim.fn.feedkeys("G", "n")
end end
-- The Configure command -- The Configure command
@@ -110,7 +64,7 @@ vim.api.nvim_create_user_command("Configure", function(a)
if #a.args > 0 then bt = a.args end if #a.args > 0 then bt = a.args end
workspace.build_type = bt workspace.build_type = bt
updateBuildEnv() updateBuildEnv()
writeWorkspace() WriteWorkspace()
end, { nargs = '?', desc = "Update run/debug arguments" }) end, { nargs = '?', desc = "Update run/debug arguments" })
vim.api.nvim_create_user_command("Args", function(a) updateArgs(a.fargs) end, vim.api.nvim_create_user_command("Args", function(a) updateArgs(a.fargs) end,
@@ -125,7 +79,7 @@ if dap_ok then
} }
dap_configs = { dap_configs = {
{ {
name = 'test all', name = 'default',
type = 'codelldb', type = 'codelldb',
request = 'launch', request = 'launch',
program = bin_name, program = bin_name,
@@ -147,27 +101,36 @@ if dap_ok then
end, { nargs = '*', desc = "Starts debugging with specified arguments" }) end, { nargs = '*', desc = "Starts debugging with specified arguments" })
end end
if MakeAnd then local r = function()
local r = function()
MakeAnd(function() MakeAnd(function()
TermRun(bin_name .. " " .. table.concat(workspace.args, " ")) TermRun(bin_name .. " " .. table.concat(workspace.args, " "))
end) end)
end end
-- RunArgs sets the run arguments that F6 uses and reruns immediately -- RunArgs sets the run arguments that F6 uses and reruns immediately
vim.api.nvim_create_user_command("RunArgs", function(a) vim.api.nvim_create_user_command("RunArgs", function(a)
updateArgs(a.fargs) updateArgs(a.fargs)
r() r()
end, { nargs = '*', desc = "Starts debugging with specified arguments" }) end, { nargs = '*', desc = "Starts debugging with specified arguments" })
-- F6 to run the application -- F6 to run the application
vim.keymap.set('n', '<F6>', r) 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 if dap_ok then
-- Shift-F5 to launch default config -- Shift-F5 to launch default config
vim.keymap.set('n', '<F17>', function() vim.keymap.set('n', '<F17>', function()
MakeAnd(function() MakeAnd(function()
dap.run(dap_def_cfg) dap.run(dap_def_cfg)
end) end)
end) end)
end
end end

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,13 +1,16 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use pulldown_cmark::{html, Options, Parser}; 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 {
pub name: String, pub name: String,
pub title: String,
#[allow(dead_code)] #[allow(dead_code)]
pub filename: String, pub filename: String,
#[allow(dead_code)] #[allow(dead_code)]
@@ -29,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";
@@ -41,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()?;
@@ -143,9 +148,10 @@ impl PostManager {
} }
let markdown_content = fs::read_to_string(&entry.file_path) let markdown_content = fs::read_to_string(&entry.file_path)
.map_err(|e| format!("Failed to read post file: {}", e))?; .map_err(|e| format!("Failed to read post file: {}", e))?;
let html_content = markdown_to_html(&markdown_content); let (html_content, title) = markdown_to_html(&markdown_content);
let post = Post { let post = Post {
name: name.clone(), name: name.clone(),
title: title,
filename: entry.file_path, filename: entry.file_path,
markdown_content, markdown_content,
html_content, html_content,
@@ -153,43 +159,95 @@ 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
} }
} }
fn markdown_to_html(markdown: &str) -> String { 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(); let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_TABLES);
@@ -197,8 +255,10 @@ fn markdown_to_html(markdown: &str) -> String {
options.insert(Options::ENABLE_TASKLISTS); options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_SMART_PUNCTUATION); options.insert(Options::ENABLE_SMART_PUNCTUATION);
let title = markdown_title(markdown).unwrap_or("unknown".to_string());
let parser = Parser::new_ext(markdown, options); let parser = Parser::new_ext(markdown, options);
let mut html_output = String::new(); let mut html_output = String::new();
html::push_html(&mut html_output, parser); html::push_html(&mut html_output, parser);
html_output (html_output, title)
} }

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,17 +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" => {
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" => { "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())
@@ -189,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");
@@ -203,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")
)); ));
} }
@@ -212,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>