Neste projeto vamos construir um motor de blog em Rust que lê arquivos Markdown de um diretório, converte para HTML e serve páginas web completas através do framework Axum. O blog terá listagem de posts com resumos, visualização individual de cada post, templates HTML simples e suporte a metadados no frontmatter YAML dos arquivos Markdown.
Este é um projeto que combina manipulação de arquivos, parsing de texto e servir conteúdo web – habilidades essenciais para qualquer desenvolvedor Rust que trabalha com aplicações web.
O Que Vamos Construir
Um motor de blog com as seguintes funcionalidades:
- Leitura de arquivos
.mdde um diretórioposts/ - Parsing de frontmatter YAML (título, data, resumo)
- Conversão de Markdown para HTML com
pulldown-cmark - Página inicial com lista de posts ordenados por data
- Página individual de cada post com conteúdo completo
- Templates HTML inline com CSS básico
- Resposta automática a novos arquivos (releitura a cada requisição)
Estrutura do Projeto
blog-engine/
├── Cargo.toml
├── posts/
│ ├── primeiro-post.md
│ └── aprendendo-rust.md
└── src/
└── main.rs
Configurando o Projeto
cargo new blog-engine
cd blog-engine
mkdir posts
Configure o Cargo.toml:
[package]
name = "blog-engine"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
pulldown-cmark = "0.12"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
chrono = { version = "0.4", features = ["serde"] }
Usamos pulldown-cmark para converter Markdown em HTML e serde_yaml para parsing do frontmatter.
Crie dois arquivos de exemplo na pasta posts/:
posts/primeiro-post.md:
---
titulo: "Meu Primeiro Post"
data: "2026-02-20"
resumo: "Este é o meu primeiro post no blog construído em Rust!"
---
# Meu Primeiro Post
Bem-vindo ao meu blog construído inteiramente em **Rust**!
## Por que Rust?
Rust combina performance de linguagens de sistema com segurança de memória
garantida em tempo de compilação. Isso a torna ideal para:
- Servidores web de alto desempenho
- Ferramentas de linha de comando
- Sistemas embarcados
## Próximos Passos
Vou continuar explorando o ecossistema Rust e compartilhando o que aprendo.
posts/aprendendo-rust.md:
---
titulo: "Aprendendo Rust na Prática"
data: "2026-02-22"
resumo: "Dicas práticas para quem está começando a programar em Rust."
---
# Aprendendo Rust na Prática
Depois de algumas semanas estudando Rust, quero compartilhar dicas
que me ajudaram no aprendizado.
## Dica 1: Comece Pequeno
Não tente construir um sistema operacional no primeiro dia.
Comece com programas simples e vá aumentando a complexidade.
## Dica 2: Leia os Erros do Compilador
O compilador de Rust é seu melhor professor. Leia cada mensagem
de erro com atenção -- elas geralmente indicam exatamente o que
fazer para corrigir o problema.
## Dica 3: Pratique Ownership
O sistema de ownership é o conceito mais importante de Rust.
Pratique escrevendo código que move, empresta e clona valores
até se sentir confortável.
Passo 1: Parsing de Frontmatter e Markdown
Vamos criar as estruturas e funções para ler e processar os arquivos Markdown:
use chrono::NaiveDate;
use pulldown_cmark::{html, Parser};
use serde::Deserialize;
use std::fs;
use std::path::Path;
// Metadados extraídos do frontmatter YAML
#[derive(Debug, Clone, Deserialize)]
pub struct Frontmatter {
pub titulo: String,
pub data: String,
pub resumo: String,
}
// Post completo processado
#[derive(Debug, Clone)]
pub struct Post {
pub slug: String,
pub titulo: String,
pub data: NaiveDate,
pub resumo: String,
pub conteudo_html: String,
}
// Separa o frontmatter YAML do conteúdo Markdown
fn separar_frontmatter(conteudo: &str) -> Option<(&str, &str)> {
let conteudo = conteudo.trim_start();
if !conteudo.starts_with("---") {
return None;
}
let apos_primeiro = &conteudo[3..];
let fim_frontmatter = apos_primeiro.find("---")?;
let yaml = &apos_primeiro[..fim_frontmatter].trim();
let markdown = &apos_primeiro[fim_frontmatter + 3..].trim();
Some((yaml, markdown))
}
// Converte Markdown para HTML
fn markdown_para_html(markdown: &str) -> String {
let parser = Parser::new(markdown);
let mut html_saida = String::new();
html::push_html(&mut html_saida, parser);
html_saida
}
// Carrega e processa um arquivo .md
fn carregar_post(caminho: &Path) -> Option<Post> {
let conteudo = fs::read_to_string(caminho).ok()?;
let (yaml, markdown) = separar_frontmatter(&conteudo)?;
let frontmatter: Frontmatter = serde_yaml::from_str(yaml).ok()?;
let slug = caminho
.file_stem()?
.to_str()?
.to_string();
let data = NaiveDate::parse_from_str(&frontmatter.data, "%Y-%m-%d").ok()?;
Some(Post {
slug,
titulo: frontmatter.titulo,
data,
resumo: frontmatter.resumo,
conteudo_html: markdown_para_html(markdown),
})
}
// Carrega todos os posts do diretório, ordenados por data (mais recente primeiro)
fn carregar_todos_os_posts(diretorio: &str) -> Vec<Post> {
let mut posts = Vec::new();
if let Ok(entradas) = fs::read_dir(diretorio) {
for entrada in entradas.flatten() {
let caminho = entrada.path();
if caminho.extension().and_then(|e| e.to_str()) == Some("md") {
if let Some(post) = carregar_post(&caminho) {
posts.push(post);
}
}
}
}
posts.sort_by(|a, b| b.data.cmp(&a.data));
posts
}
A função separar_frontmatter divide o arquivo em duas partes: os metadados YAML entre --- e o conteúdo Markdown. O pulldown-cmark converte o Markdown em HTML seguro e rápido.
Passo 2: Templates HTML
Vamos criar funções que geram HTML completo com CSS inline:
const CSS_ESTILO: &str = r#"
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto;
padding: 20px; background: #fafafa; color: #333; line-height: 1.7; }
h1 { color: #b7410e; border-bottom: 2px solid #b7410e; padding-bottom: 10px; }
h2, h3 { color: #444; }
a { color: #b7410e; text-decoration: none; }
a:hover { text-decoration: underline; }
.post-card { background: white; padding: 20px; margin: 15px 0;
border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.post-card h2 { margin-top: 0; }
.data { color: #888; font-size: 0.9em; }
.voltar { display: inline-block; margin-bottom: 20px; }
nav { margin-bottom: 30px; padding: 15px 0; border-bottom: 1px solid #ddd; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
pre code { display: block; padding: 15px; overflow-x: auto; }
"#;
fn template_pagina(titulo: &str, conteudo: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{titulo}</title>
<style>{CSS_ESTILO}</style>
</head>
<body>
<nav><a href="/">Blog Rust</a></nav>
{conteudo}
</body>
</html>"#,
titulo = titulo,
conteudo = conteudo,
)
}
fn renderizar_lista_posts(posts: &[Post]) -> String {
let mut cards = String::new();
for post in posts {
cards.push_str(&format!(
r#"<div class="post-card">
<h2><a href="/post/{slug}">{titulo}</a></h2>
<p class="data">{data}</p>
<p>{resumo}</p>
</div>"#,
slug = post.slug,
titulo = post.titulo,
data = post.data.format("%d/%m/%Y"),
resumo = post.resumo,
));
}
let conteudo = format!("<h1>Blog Rust</h1>\n{}", cards);
template_pagina("Blog Rust", &conteudo)
}
fn renderizar_post(post: &Post) -> String {
let conteudo = format!(
r#"<a href="/" class="voltar">Voltar para a lista</a>
<article>
<h1>{titulo}</h1>
<p class="data">Publicado em {data}</p>
{conteudo_html}
</article>"#,
titulo = post.titulo,
data = post.data.format("%d/%m/%Y"),
conteudo_html = post.conteudo_html,
);
template_pagina(&post.titulo, &conteudo)
}
Os templates são funções puras que recebem dados e retornam strings HTML. O CSS inline garante que o blog tenha uma aparência limpa sem precisar de arquivos estáticos separados.
Passo 3: Handlers e Rotas do Axum
Agora vamos conectar tudo ao servidor web:
use axum::{
extract::Path as AxumPath,
http::StatusCode,
response::Html,
routing::get,
Router,
};
const DIRETORIO_POSTS: &str = "posts";
// GET / — Página inicial com lista de posts
async fn pagina_inicial() -> Html<String> {
let posts = carregar_todos_os_posts(DIRETORIO_POSTS);
Html(renderizar_lista_posts(&posts))
}
// GET /post/:slug — Página individual do post
async fn pagina_post(
AxumPath(slug): AxumPath<String>,
) -> Result<Html<String>, StatusCode> {
let posts = carregar_todos_os_posts(DIRETORIO_POSTS);
let post = posts
.into_iter()
.find(|p| p.slug == slug)
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Html(renderizar_post(&post)))
}
Os handlers retornam Html<String>, que configura automaticamente o Content-Type: text/html. A cada requisição, os posts são relidos do disco, permitindo que novos arquivos apareçam sem reiniciar o servidor.
Passo 4: Montando o main.rs Completo
Aqui está o código completo do src/main.rs:
use axum::{
extract::Path as AxumPath,
http::StatusCode,
response::Html,
routing::get,
Router,
};
use chrono::NaiveDate;
use pulldown_cmark::{html, Parser};
use serde::Deserialize;
use std::fs;
use std::path::Path;
// === Modelos ===
#[derive(Debug, Clone, Deserialize)]
pub struct Frontmatter {
pub titulo: String,
pub data: String,
pub resumo: String,
}
#[derive(Debug, Clone)]
pub struct Post {
pub slug: String,
pub titulo: String,
pub data: NaiveDate,
pub resumo: String,
pub conteudo_html: String,
}
// === Parsing ===
fn separar_frontmatter(conteudo: &str) -> Option<(&str, &str)> {
let conteudo = conteudo.trim_start();
if !conteudo.starts_with("---") {
return None;
}
let apos_primeiro = &conteudo[3..];
let fim = apos_primeiro.find("---")?;
Some((apos_primeiro[..fim].trim(), apos_primeiro[fim + 3..].trim()))
}
fn markdown_para_html(markdown: &str) -> String {
let parser = Parser::new(markdown);
let mut saida = String::new();
html::push_html(&mut saida, parser);
saida
}
fn carregar_post(caminho: &Path) -> Option<Post> {
let conteudo = fs::read_to_string(caminho).ok()?;
let (yaml, markdown) = separar_frontmatter(&conteudo)?;
let fm: Frontmatter = serde_yaml::from_str(yaml).ok()?;
let slug = caminho.file_stem()?.to_str()?.to_string();
let data = NaiveDate::parse_from_str(&fm.data, "%Y-%m-%d").ok()?;
Some(Post {
slug,
titulo: fm.titulo,
data,
resumo: fm.resumo,
conteudo_html: markdown_para_html(markdown),
})
}
fn carregar_todos_os_posts(diretorio: &str) -> Vec<Post> {
let mut posts = Vec::new();
if let Ok(entradas) = fs::read_dir(diretorio) {
for entrada in entradas.flatten() {
let caminho = entrada.path();
if caminho.extension().and_then(|e| e.to_str()) == Some("md") {
if let Some(post) = carregar_post(&caminho) {
posts.push(post);
}
}
}
}
posts.sort_by(|a, b| b.data.cmp(&a.data));
posts
}
// === Templates ===
const CSS_ESTILO: &str = r#"
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto;
padding: 20px; background: #fafafa; color: #333; line-height: 1.7; }
h1 { color: #b7410e; border-bottom: 2px solid #b7410e; padding-bottom: 10px; }
h2, h3 { color: #444; }
a { color: #b7410e; text-decoration: none; }
a:hover { text-decoration: underline; }
.post-card { background: white; padding: 20px; margin: 15px 0;
border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.post-card h2 { margin-top: 0; }
.data { color: #888; font-size: 0.9em; }
.voltar { display: inline-block; margin-bottom: 20px; }
nav { margin-bottom: 30px; padding: 15px 0; border-bottom: 1px solid #ddd; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
pre code { display: block; padding: 15px; overflow-x: auto; }
"#;
fn template_pagina(titulo: &str, conteudo: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{titulo}</title>
<style>{CSS_ESTILO}</style>
</head>
<body>
<nav><a href="/">Blog Rust</a></nav>
{conteudo}
</body>
</html>"#
)
}
fn renderizar_lista_posts(posts: &[Post]) -> String {
let mut cards = String::new();
for post in posts {
cards.push_str(&format!(
r#"<div class="post-card">
<h2><a href="/post/{slug}">{titulo}</a></h2>
<p class="data">{data}</p>
<p>{resumo}</p>
</div>"#,
slug = post.slug,
titulo = post.titulo,
data = post.data.format("%d/%m/%Y"),
resumo = post.resumo,
));
}
template_pagina("Blog Rust", &format!("<h1>Blog Rust</h1>\n{}", cards))
}
fn renderizar_post(post: &Post) -> String {
let conteudo = format!(
r#"<a href="/" class="voltar">Voltar para a lista</a>
<article>
<h1>{titulo}</h1>
<p class="data">Publicado em {data}</p>
{html}
</article>"#,
titulo = post.titulo,
data = post.data.format("%d/%m/%Y"),
html = post.conteudo_html,
);
template_pagina(&post.titulo, &conteudo)
}
// === Handlers ===
const DIRETORIO_POSTS: &str = "posts";
async fn pagina_inicial() -> Html<String> {
let posts = carregar_todos_os_posts(DIRETORIO_POSTS);
Html(renderizar_lista_posts(&posts))
}
async fn pagina_post(
AxumPath(slug): AxumPath<String>,
) -> Result<Html<String>, StatusCode> {
let posts = carregar_todos_os_posts(DIRETORIO_POSTS);
let post = posts
.into_iter()
.find(|p| p.slug == slug)
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Html(renderizar_post(&post)))
}
// === Main ===
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(pagina_inicial))
.route("/post/{slug}", get(pagina_post));
let endereco = "0.0.0.0:3000";
println!("Blog rodando em http://{}", endereco);
println!("Coloque seus posts Markdown na pasta posts/");
let listener = tokio::net::TcpListener::bind(endereco).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Como Executar
Certifique-se de que a pasta posts/ existe com pelo menos um arquivo .md:
# Crie a pasta e um post de exemplo
mkdir -p posts
# Inicie o servidor
cargo run
Abra o navegador em http://localhost:3000 para ver a lista de posts. Clique em um post para ver o conteúdo completo. Adicione novos arquivos .md na pasta posts/ e recarregue a página para vê-los aparecer.
# Teste via curl
curl http://localhost:3000
curl http://localhost:3000/post/primeiro-post
Desafios para Expandir
Cache de posts — Implemente um cache em memória que recarrega os posts apenas quando os arquivos são modificados, usando a data de modificação do sistema de arquivos.
Tags e categorias — Adicione suporte a tags no frontmatter e crie páginas de listagem por tag (
/tag/rust), permitindo navegar posts por assunto.Feed RSS — Gere um feed RSS/Atom em
/feed.xmlcom os posts mais recentes, permitindo que leitores assinem o blog.Syntax highlighting — Integre a crate
syntectpara destacar blocos de código nos posts com cores de sintaxe, detectando automaticamente a linguagem.Sistema de busca — Implemente uma busca full-text simples que permite filtrar posts por palavras-chave no título e conteúdo.
Veja Também
- Módulo fs da Biblioteca Padrão — Operações de leitura de arquivos usadas neste projeto
- Path e PathBuf — Manipulação de caminhos de arquivo
- String em Rust — Trabalho com strings e texto
- Lendo Arquivos em Rust — Receitas para leitura de arquivos
- Rust para Desenvolvimento Web — Panorama do ecossistema web em Rust