Initial commit
Initial commit Add lua template
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
/.nvim.lua
|
||||
/.nvim.workspace.lua
|
||||
1038
Cargo.lock
generated
Normal file
1038
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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"
|
||||
regex = "1.10"
|
||||
once_cell = "1.19"
|
||||
9
PROMPT.md
Normal file
9
PROMPT.md
Normal file
@@ -0,0 +1,9 @@
|
||||
> Can you create a rust web server application for my static blog, that includes cmark and parses inside the posts/ folder, it should generate an
|
||||
index page that lists top say 50 posts, also serve an /all page that lists the same but not limited. each links to a /p/<post-name> where post name
|
||||
matches the filename. The app should keep some internal state that is refreshed upon git tag changes, check the git version from the data folder
|
||||
(which is also this project folder. Do this like in ~/Projects/lemons/src/tools/git_info/git_info.cpp) The internal state keeps track of all
|
||||
articles and their creation dates (mock dates for now as we add this later). Feel free to create some example posts. Also the page serving should
|
||||
be data driven like, all templates inside the templates/ folder act as a page, but we parse the and whenever we encounter $<tag arg=something
|
||||
arg=other/> we treat this as some built in functionality and generate stuff, things this should support are $<post-html> for generated post html
|
||||
$<post-list> with optional $<post-list limit=50> for the main page and $<include src="other.html"> for including other files inline. Thats all
|
||||
|
||||
62
posts/async-rust.md
Normal file
62
posts/async-rust.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Async Programming in Rust
|
||||
|
||||
Asynchronous programming in Rust allows you to write concurrent code that's both fast and safe.
|
||||
|
||||
## What is Async?
|
||||
|
||||
Async programming is about doing multiple things at once without blocking. Instead of waiting for one operation to complete before starting another, async code can switch between tasks.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Futures
|
||||
|
||||
A `Future` represents a value that may not be available yet:
|
||||
|
||||
```rust
|
||||
async fn fetch_data() -> String {
|
||||
// Simulate async work
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
"Data fetched!".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
### Async/Await
|
||||
|
||||
The `async` keyword makes a function asynchronous, and `await` pauses execution until a future is ready:
|
||||
|
||||
```rust
|
||||
async fn process_data() {
|
||||
let data = fetch_data().await;
|
||||
println!("Got: {}", data);
|
||||
}
|
||||
```
|
||||
|
||||
### Runtimes
|
||||
|
||||
Rust doesn't have a built-in async runtime. Popular choices include:
|
||||
|
||||
- **Tokio**: Full-featured runtime
|
||||
- **async-std**: Standard library approach
|
||||
- **smol**: Minimal runtime
|
||||
|
||||
## Example: Concurrent HTTP Requests
|
||||
|
||||
```rust
|
||||
use tokio::task;
|
||||
|
||||
async fn fetch_all() {
|
||||
let task1 = task::spawn(fetch_url("https://api1.example.com"));
|
||||
let task2 = task::spawn(fetch_url("https://api2.example.com"));
|
||||
let task3 = task::spawn(fetch_url("https://api3.example.com"));
|
||||
|
||||
let (result1, result2, result3) = tokio::join!(task1, task2, task3);
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Performance**: Handle thousands of connections efficiently
|
||||
- **Resource Usage**: Lower memory and CPU usage
|
||||
- **Scalability**: Build systems that scale
|
||||
|
||||
Async Rust might seem complex at first, but it's worth learning for building high-performance applications!
|
||||
91
posts/git-workflow.md
Normal file
91
posts/git-workflow.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Git Workflow Best Practices
|
||||
|
||||
Git is an essential tool for modern software development. Here are some best practices for an effective Git workflow.
|
||||
|
||||
## Branching Strategy
|
||||
|
||||
### Main Branches
|
||||
|
||||
- **main/master**: Production-ready code
|
||||
- **develop**: Integration branch for features
|
||||
|
||||
### Supporting Branches
|
||||
|
||||
- **feature/**: New features
|
||||
- **bugfix/**: Bug fixes
|
||||
- **hotfix/**: Urgent production fixes
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Good commit messages are crucial:
|
||||
|
||||
```
|
||||
Add user authentication system
|
||||
|
||||
- Implement JWT token generation
|
||||
- Add login and logout endpoints
|
||||
- Create user session middleware
|
||||
```
|
||||
|
||||
Follow the 50/72 rule:
|
||||
- First line: 50 characters or less
|
||||
- Body: Wrap at 72 characters
|
||||
|
||||
## Commands
|
||||
|
||||
### Creating a Feature Branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/new-feature develop
|
||||
```
|
||||
|
||||
### Committing Changes
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add feature description"
|
||||
```
|
||||
|
||||
### Merging
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git merge --no-ff feature/new-feature
|
||||
git branch -d feature/new-feature
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Commit Often**: Make small, logical commits
|
||||
2. **Pull Regularly**: Stay in sync with the team
|
||||
3. **Review Before Commit**: Use `git diff` to check changes
|
||||
4. **Use .gitignore**: Don't commit build artifacts or secrets
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Interactive Rebase
|
||||
|
||||
Clean up commit history:
|
||||
|
||||
```bash
|
||||
git rebase -i HEAD~3
|
||||
```
|
||||
|
||||
### Stashing
|
||||
|
||||
Save work in progress:
|
||||
|
||||
```bash
|
||||
git stash
|
||||
git stash pop
|
||||
```
|
||||
|
||||
### Cherry-pick
|
||||
|
||||
Apply specific commits:
|
||||
|
||||
```bash
|
||||
git cherry-pick <commit-hash>
|
||||
```
|
||||
|
||||
Master Git, and you'll be a more effective developer!
|
||||
38
posts/hello-world.md
Normal file
38
posts/hello-world.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Hello World
|
||||
|
||||
Welcome to my blog! This is my first post written in Markdown and served by a custom Rust web server.
|
||||
|
||||
## Features
|
||||
|
||||
This blog supports:
|
||||
|
||||
- **CommonMark** markdown parsing
|
||||
- Git-based cache invalidation
|
||||
- Template-driven rendering
|
||||
- Custom template tags
|
||||
|
||||
## Code Example
|
||||
|
||||
Here's a simple Rust function:
|
||||
|
||||
```rust
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}!", name)
|
||||
}
|
||||
```
|
||||
|
||||
## Lists
|
||||
|
||||
Unordered list:
|
||||
- Item one
|
||||
- Item two
|
||||
- Item three
|
||||
|
||||
Ordered list:
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
|
||||
---
|
||||
|
||||
Thanks for reading!
|
||||
62
posts/markdown-guide.md
Normal file
62
posts/markdown-guide.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Markdown Guide
|
||||
|
||||
This blog uses CommonMark for rendering markdown content. Here's a quick guide to the syntax.
|
||||
|
||||
## Headers
|
||||
|
||||
Use `#` for headers:
|
||||
|
||||
```markdown
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
```
|
||||
|
||||
## Emphasis
|
||||
|
||||
- *Italic* with `*asterisks*`
|
||||
- **Bold** with `**double asterisks**`
|
||||
- ~~Strikethrough~~ with `~~tildes~~`
|
||||
|
||||
## Links and Images
|
||||
|
||||
Links: `[Link text](https://example.com)`
|
||||
|
||||
Images: ``
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> This is a blockquote.
|
||||
> It can span multiple lines.
|
||||
|
||||
## Code
|
||||
|
||||
Inline `code` with backticks.
|
||||
|
||||
Code blocks with triple backticks:
|
||||
|
||||
```python
|
||||
def hello():
|
||||
print("Hello, world!")
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
| Column 1 | Column 2 | Column 3 |
|
||||
|----------|----------|----------|
|
||||
| Data 1 | Data 2 | Data 3 |
|
||||
| More | Data | Here |
|
||||
|
||||
## Task Lists
|
||||
|
||||
- [x] Completed task
|
||||
- [ ] Incomplete task
|
||||
- [ ] Another task
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
Use three or more hyphens:
|
||||
|
||||
---
|
||||
|
||||
That's the basics of Markdown!
|
||||
57
posts/rust-web-servers.md
Normal file
57
posts/rust-web-servers.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Building Web Servers in Rust
|
||||
|
||||
Rust has become a popular choice for building web servers due to its performance and safety guarantees.
|
||||
|
||||
## Why Rust for Web Development?
|
||||
|
||||
1. **Memory Safety**: No null pointer exceptions or data races
|
||||
2. **Performance**: Comparable to C/C++
|
||||
3. **Concurrency**: Fearless concurrency with async/await
|
||||
4. **Type System**: Catch errors at compile time
|
||||
|
||||
## Popular Frameworks
|
||||
|
||||
### Axum
|
||||
|
||||
Axum is a modern web framework built on top of Tokio. It provides:
|
||||
|
||||
- Type-safe routing
|
||||
- Extractors for parsing requests
|
||||
- Middleware support
|
||||
- Great ergonomics
|
||||
|
||||
### Actix Web
|
||||
|
||||
Actix Web is a powerful, pragmatic framework that:
|
||||
|
||||
- Uses the actor model
|
||||
- Provides excellent performance
|
||||
- Has a mature ecosystem
|
||||
|
||||
### Rocket
|
||||
|
||||
Rocket focuses on:
|
||||
|
||||
- Developer ergonomics
|
||||
- Type-safe routing
|
||||
- Easy to learn
|
||||
|
||||
## Example Server
|
||||
|
||||
```rust
|
||||
use axum::{routing::get, Router};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "Hello, World!" }));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
This is just the beginning of what you can build with Rust!
|
||||
158
project_templates/.nvim.lua
Normal file
158
project_templates/.nvim.lua
Normal file
@@ -0,0 +1,158 @@
|
||||
-- Load persistent configuration from .workspace.lua
|
||||
local ws_file = "./.nvim.workspace.lua"
|
||||
local loaded, workspace = pcall(dofile, ws_file)
|
||||
if not loaded then
|
||||
workspace = { args = { }, build_type = "debug", binary = "blog-server" }
|
||||
end
|
||||
|
||||
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"
|
||||
if workspace.build_type == "release" then
|
||||
vim.opt.makeprg = vim.opt.makeprg .. " --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()
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
-- 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
|
||||
|
||||
-- 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 lldb_init = { }
|
||||
dap_configs = {
|
||||
{
|
||||
name = 'test all',
|
||||
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
|
||||
|
||||
if MakeAnd then
|
||||
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)
|
||||
|
||||
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
|
||||
end
|
||||
68
src/git_tracker.rs
Normal file
68
src/git_tracker.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitVersion {
|
||||
pub branch: String,
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
impl GitVersion {
|
||||
pub fn key(&self) -> String {
|
||||
format!("{}/{}", self.branch, self.commit)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_git_version(git_dir: &str) -> Result<GitVersion, String> {
|
||||
let git_path = Path::new(git_dir);
|
||||
let head_path = git_path.join(".git/HEAD");
|
||||
|
||||
// Read HEAD file
|
||||
let head_content = fs::read_to_string(&head_path)
|
||||
.map_err(|e| format!("Failed to read .git/HEAD: {}", e))?;
|
||||
|
||||
let head_content = head_content.trim();
|
||||
|
||||
// Check if HEAD points to a ref
|
||||
if head_content.starts_with("ref:") {
|
||||
let ref_path = head_content.strip_prefix("ref:").unwrap().trim();
|
||||
let full_ref_path = git_path.join(".git").join(ref_path);
|
||||
|
||||
// Read the ref file to get the commit hash
|
||||
let commit_hash = fs::read_to_string(&full_ref_path)
|
||||
.map_err(|e| format!("Failed to read ref file: {}", e))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Extract branch name from ref path
|
||||
let branch = ref_path
|
||||
.strip_prefix("refs/heads/")
|
||||
.unwrap_or(ref_path)
|
||||
.to_string();
|
||||
|
||||
Ok(GitVersion {
|
||||
branch,
|
||||
commit: commit_hash,
|
||||
})
|
||||
} else {
|
||||
// Detached HEAD state - just use the commit hash
|
||||
Ok(GitVersion {
|
||||
branch: "detached".to_string(),
|
||||
commit: head_content.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_git_version() {
|
||||
// This test will only work if run in a git repository
|
||||
if let Ok(version) = get_git_version(".") {
|
||||
assert!(!version.commit.is_empty());
|
||||
assert!(!version.branch.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/main.rs
Normal file
116
src/main.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
mod git_tracker;
|
||||
mod post_manager;
|
||||
mod template_engine;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
post_manager: Arc<RwLock<post_manager::PostManager>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let post_manager = Arc::new(RwLock::new(
|
||||
post_manager::PostManager::new(".").expect("Failed to initialize post manager"),
|
||||
));
|
||||
|
||||
let app_state = AppState {
|
||||
post_manager: post_manager.clone(),
|
||||
};
|
||||
|
||||
// Spawn background task to watch for git changes
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut manager = post_manager.write().await;
|
||||
if let Err(e) = manager.refresh_if_needed() {
|
||||
eprintln!("Error refreshing posts: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/:page", get(all_handler))
|
||||
.route("/p/:post_name", get(post_handler))
|
||||
.with_state(app_state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
|
||||
.await
|
||||
.expect("Failed to bind to address");
|
||||
|
||||
println!("Server running on http://127.0.0.1:3000");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Failed to start server");
|
||||
}
|
||||
|
||||
async fn index_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match render_template("index.html", &state, Some(50)).await {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn all_handler(State(state): State<AppState>, Path(page): Path<String>) -> impl IntoResponse {
|
||||
if page.contains("..") {
|
||||
return (StatusCode::NOT_FOUND, "Invalid path").into_response();
|
||||
}
|
||||
match render_template(&format!("page_{}.html", page), &state, None).await {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(post_name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let manager = state.post_manager.read().await;
|
||||
|
||||
if post_name.contains("..") || post_name.contains("/") {
|
||||
return (StatusCode::NOT_FOUND, "Invalid path").into_response();
|
||||
}
|
||||
match manager.get_post(&post_name) {
|
||||
Some(_post) => {
|
||||
drop(manager);
|
||||
match render_post_template(&state, post_name).await {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "Post not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_template(
|
||||
template_name: &str,
|
||||
state: &AppState,
|
||||
limit: Option<usize>,
|
||||
) -> Result<String, String> {
|
||||
let template_path = format!("templates/{}", template_name);
|
||||
let template_content = std::fs::read_to_string(&template_path)
|
||||
.map_err(|e| format!("Failed to read template: {}", e))?;
|
||||
|
||||
let manager = state.post_manager.read().await;
|
||||
template_engine::render_template(&template_content, &*manager, None, limit)
|
||||
}
|
||||
|
||||
async fn render_post_template(state: &AppState, post_name: String) -> Result<String, String> {
|
||||
let template_content = std::fs::read_to_string("templates/post.html")
|
||||
.map_err(|e| format!("Failed to read post template: {}", e))?;
|
||||
|
||||
let manager = state.post_manager.read().await;
|
||||
template_engine::render_template(&template_content, &*manager, Some(&post_name), None)
|
||||
}
|
||||
148
src/post_manager.rs
Normal file
148
src/post_manager.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use crate::git_tracker::{get_git_version, GitVersion};
|
||||
use chrono::{DateTime, Utc};
|
||||
use pulldown_cmark::{html, Options, Parser};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Post {
|
||||
pub name: String,
|
||||
pub filename: String,
|
||||
pub markdown_content: String,
|
||||
pub html_content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct PostManager {
|
||||
git_dir: PathBuf,
|
||||
posts_dir: PathBuf,
|
||||
posts: HashMap<String, Post>,
|
||||
last_git_version: Option<GitVersion>,
|
||||
}
|
||||
|
||||
impl PostManager {
|
||||
pub fn new(root_dir: &str) -> Result<Self, String> {
|
||||
let git_dir = PathBuf::from(root_dir);
|
||||
let posts_dir = git_dir.join("posts");
|
||||
|
||||
// Create posts directory if it doesn't exist
|
||||
if !posts_dir.exists() {
|
||||
fs::create_dir_all(&posts_dir)
|
||||
.map_err(|e| format!("Failed to create posts directory: {}", e))?;
|
||||
}
|
||||
|
||||
let mut manager = PostManager {
|
||||
git_dir,
|
||||
posts_dir,
|
||||
posts: HashMap::new(),
|
||||
last_git_version: None,
|
||||
};
|
||||
|
||||
manager.refresh_posts()?;
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub fn refresh_if_needed(&mut self) -> Result<bool, String> {
|
||||
let current_version = get_git_version(self.git_dir.to_str().unwrap())?;
|
||||
|
||||
if self.last_git_version.as_ref() != Some(¤t_version) {
|
||||
println!(
|
||||
"Git version changed: {} -> {}",
|
||||
self.last_git_version
|
||||
.as_ref()
|
||||
.map(|v| v.key())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
current_version.key()
|
||||
);
|
||||
self.refresh_posts()?;
|
||||
self.last_git_version = Some(current_version);
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_posts(&mut self) -> Result<(), String> {
|
||||
self.posts.clear();
|
||||
|
||||
let entries = fs::read_dir(&self.posts_dir)
|
||||
.map_err(|e| format!("Failed to read posts directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| "Invalid filename".to_string())?
|
||||
.to_string();
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| "Invalid file stem".to_string())?
|
||||
.to_string();
|
||||
|
||||
let markdown_content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read post file: {}", e))?;
|
||||
|
||||
let html_content = markdown_to_html(&markdown_content);
|
||||
|
||||
// Mock creation date for now
|
||||
let metadata = fs::metadata(&path)
|
||||
.map_err(|e| format!("Failed to read file metadata: {}", e))?;
|
||||
let created_at = metadata
|
||||
.created()
|
||||
.or_else(|_| metadata.modified())
|
||||
.map(|t| DateTime::<Utc>::from(t))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
|
||||
let post = Post {
|
||||
name: name.clone(),
|
||||
filename,
|
||||
markdown_content,
|
||||
html_content,
|
||||
created_at,
|
||||
};
|
||||
|
||||
self.posts.insert(name, post);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Loaded {} posts", self.posts.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_post(&self, name: &str) -> Option<&Post> {
|
||||
self.posts.get(name)
|
||||
}
|
||||
|
||||
pub fn get_all_posts(&self) -> Vec<&Post> {
|
||||
let mut posts: Vec<&Post> = self.posts.values().collect();
|
||||
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
posts
|
||||
}
|
||||
|
||||
pub fn get_posts_limited(&self, limit: usize) -> Vec<&Post> {
|
||||
let mut posts = self.get_all_posts();
|
||||
posts.truncate(limit);
|
||||
posts
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown_to_html(markdown: &str) -> String {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||
|
||||
let parser = Parser::new_ext(markdown, options);
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
html_output
|
||||
}
|
||||
140
src/template_engine.rs
Normal file
140
src/template_engine.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::post_manager::PostManager;
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
|
||||
pub fn render_template(
|
||||
template: &str,
|
||||
post_manager: &PostManager,
|
||||
current_post: Option<&str>,
|
||||
limit_override: Option<usize>,
|
||||
) -> Result<String, String> {
|
||||
let mut result = template.to_string();
|
||||
|
||||
// Process includes first
|
||||
result = process_includes(&result)?;
|
||||
|
||||
// Process post-html tag
|
||||
if let Some(post_name) = current_post {
|
||||
result = process_post_html(&result, post_manager, post_name)?;
|
||||
}
|
||||
|
||||
// Process post-list tag
|
||||
result = process_post_list(&result, post_manager, limit_override)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn process_includes(template: &str) -> Result<String, String> {
|
||||
let include_regex = Regex::new(r#"\$<include\s+src="([^"]+)"\s*/>"#).unwrap();
|
||||
let mut result = template.to_string();
|
||||
let mut iteration = 0;
|
||||
const MAX_ITERATIONS: usize = 10;
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
if iteration > MAX_ITERATIONS {
|
||||
return Err("Too many nested includes (max 10)".to_string());
|
||||
}
|
||||
|
||||
let result_copy = result.clone();
|
||||
let captures: Vec<_> = include_regex.captures_iter(&result_copy).collect();
|
||||
|
||||
if captures.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for cap in captures {
|
||||
let full_match = cap.get(0).unwrap().as_str();
|
||||
let src = cap.get(1).unwrap().as_str();
|
||||
|
||||
let include_path = format!("templates/{}", src);
|
||||
let content = fs::read_to_string(&include_path)
|
||||
.map_err(|e| format!("Failed to read include file '{}': {}", src, e))?;
|
||||
|
||||
result = result.replace(full_match, &content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn process_post_html(
|
||||
template: &str,
|
||||
post_manager: &PostManager,
|
||||
post_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let post_html_regex = Regex::new(r"\$<post-html\s*/?>").unwrap();
|
||||
|
||||
let post = post_manager
|
||||
.get_post(post_name)
|
||||
.ok_or_else(|| format!("Post '{}' not found", post_name))?;
|
||||
|
||||
Ok(post_html_regex.replace_all(template, post.html_content.as_str()).to_string())
|
||||
}
|
||||
|
||||
fn process_post_list(
|
||||
template: &str,
|
||||
post_manager: &PostManager,
|
||||
limit_override: Option<usize>,
|
||||
) -> Result<String, String> {
|
||||
let post_list_regex = Regex::new(r#"\$<post-list(?:\s+limit=(\d+))?\s*/>"#).unwrap();
|
||||
|
||||
let mut result = template.to_string();
|
||||
|
||||
for cap in post_list_regex.captures_iter(template) {
|
||||
let full_match = cap.get(0).unwrap().as_str();
|
||||
let limit_attr = cap.get(1).and_then(|m| m.as_str().parse::<usize>().ok());
|
||||
|
||||
let limit = limit_override.or(limit_attr);
|
||||
|
||||
let posts = if let Some(l) = limit {
|
||||
post_manager.get_posts_limited(l)
|
||||
} else {
|
||||
post_manager.get_all_posts()
|
||||
};
|
||||
|
||||
let mut list_html = String::from("<ul class=\"post-list\">\n");
|
||||
for post in posts {
|
||||
list_html.push_str(&format!(
|
||||
" <li><a href=\"/p/{}\">{}</a> <span class=\"date\">{}</span></li>\n",
|
||||
post.name,
|
||||
post.name,
|
||||
post.created_at.format("%Y-%m-%d")
|
||||
));
|
||||
}
|
||||
list_html.push_str("</ul>");
|
||||
|
||||
result = result.replace(full_match, &list_html);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_post_list_regex() {
|
||||
let regex = Regex::new(r#"\$<post-list(?:\s+limit=(\d+))?\s*/>"#).unwrap();
|
||||
|
||||
assert!(regex.is_match("$<post-list/>"));
|
||||
assert!(regex.is_match("$<post-list />"));
|
||||
assert!(regex.is_match("$<post-list limit=50/>"));
|
||||
assert!(regex.is_match("$<post-list limit=50 />"));
|
||||
|
||||
let cap = regex.captures("$<post-list limit=50/>").unwrap();
|
||||
assert_eq!(cap.get(1).unwrap().as_str(), "50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_include_regex() {
|
||||
let regex = Regex::new(r#"\$<include\s+src="([^"]+)"\s*/>"#).unwrap();
|
||||
|
||||
assert!(regex.is_match(r#"$<include src="header.html"/>"#));
|
||||
assert!(regex.is_match(r#"$<include src="header.html" />"#));
|
||||
|
||||
let cap = regex.captures(r#"$<include src="header.html"/>"#).unwrap();
|
||||
assert_eq!(cap.get(1).unwrap().as_str(), "header.html");
|
||||
}
|
||||
}
|
||||
4
templates/all.html
Normal file
4
templates/all.html
Normal file
@@ -0,0 +1,4 @@
|
||||
$<include src="header.html"/>
|
||||
<h1>All Posts</h1>
|
||||
$<post-list/>
|
||||
$<include src="footer.html"/>
|
||||
5
templates/footer.html
Normal file
5
templates/footer.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<footer style="margin-top: 50px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; text-align: center;">
|
||||
<p>My Static Blog - Powered by Rust and CommonMark</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
78
templates/header.html
Normal file
78
templates/header.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Blog</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 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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/all">All Posts</a>
|
||||
</nav>
|
||||
4
templates/index.html
Normal file
4
templates/index.html
Normal file
@@ -0,0 +1,4 @@
|
||||
$<include src="header.html"/>
|
||||
<h1>Recent Posts</h1>
|
||||
$<post-list limit=50/>
|
||||
$<include src="footer.html"/>
|
||||
6
templates/post.html
Normal file
6
templates/post.html
Normal 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"/>
|
||||
Reference in New Issue
Block a user