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