Motor de Blog em Rust

Construa um motor de blog em Rust que lê arquivos Markdown, converte para HTML e serve páginas web completas com Axum e pulldown-cmark.

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 .md de um diretório posts/
  • 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

  1. 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.

  2. Tags e categorias — Adicione suporte a tags no frontmatter e crie páginas de listagem por tag (/tag/rust), permitindo navegar posts por assunto.

  3. Feed RSS — Gere um feed RSS/Atom em /feed.xml com os posts mais recentes, permitindo que leitores assinem o blog.

  4. Syntax highlighting — Integre a crate syntect para destacar blocos de código nos posts com cores de sintaxe, detectando automaticamente a linguagem.

  5. 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