Initial commit

Initial commit

Add lua template
This commit is contained in:
2024-10-01 19:24:07 +08:00
commit 29502f6816
19 changed files with 2102 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
/.nvim.lua
/.nvim.workspace.lua

1038
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

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

9
PROMPT.md Normal file
View 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
View 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
View 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
View 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
View 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: `![Alt text](image.jpg)`
## 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
View 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
View 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
View 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
View 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
View 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(&current_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
View 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
View 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
View 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
View 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
View 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
View File

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