Proyecto práctico: Generador estático de sitios (SSG) en Rust
Construiremos un SSG pequeño pero real: convierte archivos Markdown con front-matter YAML en HTML usando pulldown-cmark para renderizar Markdown, tera para plantillas y rayon para paralelizar el build. Incluiré el código completo, estructura de carpetas y explicaciones del porqué de cada decisión.
Requisitos previos
- Rust toolchain (rustup + cargo) instalado (edición 2021)
- Conocimientos básicos de Rust (propiedad, módulos, crates)
- Terminal y editor de texto
Estructura del proyecto
rust_ssg/
├─ Cargo.toml
├─ src/
│ └─ main.rs
├─ templates/
│ ├─ base.html
│ ├─ post.html
│ └─ page.html
├─ content/
│ └─ sample.md
└─ public/ # output (generado)
Ahora el código completo de los archivos principales.
Cargo.toml
[package]
name = "rust_ssg"
version = "0.1.0"
edition = "2021"
[dependencies]
walkdir = "2"
pulldown-cmark = "0.9"
tera = "1.17"
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
rayon = "1.7"
anyhow = "1.0"
clap = { version = "4.1", features = ["derive"] }
chrono = "0.4"
src/main.rs
use std::{fs, path::{Path, PathBuf}, sync::Arc};
use walkdir::WalkDir;
use pulldown_cmark::{Parser, Options, html::push_html};
use tera::{Tera, Context};
use serde::Deserialize;
use rayon::prelude::*;
use anyhow::{Result, Context as AnyhowContext};
use chrono::Utc;
use clap::Parser as ClapParser;
#[derive(ClapParser)]
struct Cli {
/// Carpeta con archivos markdown
#[arg(short, long, default_value = "content")]
content: String,
/// Carpeta con plantillas Tera
#[arg(short, long, default_value = "templates")]
templates: String,
/// Carpeta de salida
#[arg(short, long, default_value = "public")]
output: String,
}
#[derive(Debug, Deserialize, Default)]
struct FrontMatter {
title: Option,
date: Option,
template: Option,
description: Option,
slug: Option,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let tera_pattern = format!("{}/**/*", cli.templates);
let tera = Tera::new(&tera_pattern).context("Error parsing templates")?;
let tera = Arc::new(tera);
let mut md_files = Vec::new();
for entry in WalkDir::new(&cli.content).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
if entry.path().extension().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case("md")).unwrap_or(false) {
md_files.push(entry.path().to_path_buf());
}
}
}
fs::create_dir_all(&cli.output).context("creating output directory")?;
let out_dir = PathBuf::from(&cli.output);
// Paraleliza la conversión de archivos Markdown
md_files.into_par_iter().for_each(|path| {
if let Err(e) = process_file(&path, &tera, &out_dir) {
eprintln!("Error procesando {}: {:?}", path.display(), e);
}
});
println!("Build completado. Archivos en: {}", out_dir.display());
Ok(())
}
fn process_file(path: &Path, tera: &Arc, out_dir: &Path) -> Result<()> {
let text = fs::read_to_string(path).with_context(|| format!("Leyendo {}", path.display()))?;
// Extrae front matter YAML si existe (--- \n ... \n---)
let (fm_text, md) = if text.trim_start().starts_with("---") {
// Busca el siguiente '---' que cierre el front-matter
if let Some(rest_start) = text[3..].find("\n---") {
let fm = &text[3..3 + rest_start];
let content_start = 3 + rest_start + 4; // salto del cierre
let content = if content_start < text.len() { &text[content_start..] } else { "" };
(fm, content)
} else {
("", &text[..])
}
} else {
("", &text[..])
};
let fm: FrontMatter = if !fm_text.trim().is_empty() {
serde_yaml::from_str(fm_text).context("Parse front matter YAML")?
} else {
FrontMatter::default()
};
// Render Markdown -> HTML
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
let parser = Parser::new_ext(md, options);
let mut html_output = String::new();
push_html(&mut html_output, parser);
// Selecciona plantilla
let template_name = fm.template.as_deref().unwrap_or("page.html");
let mut ctx = Context::new();
ctx.insert("content", &html_output);
let title = fm.title.clone().unwrap_or_else(|| path.file_stem().unwrap().to_string_lossy().to_string());
ctx.insert("title", &title);
ctx.insert("description", &fm.description.clone().unwrap_or_default());
ctx.insert("date", &fm.date.clone().unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()));
let rendered = tera.render(template_name, &ctx).context("Render template")?;
let slug = fm.slug.clone().unwrap_or_else(|| path.file_stem().unwrap().to_string_lossy().to_string());
let out_path = out_dir.join(format!("{}.html", slug));
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).ok();
}
fs::write(&out_path, rendered).with_context(|| format!("Escribiendo {}", out_path.display()))?;
println!("Generado: {}", out_path.display());
Ok(())
}
templates/base.html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ title | default(value="Untitled") }}</title>
<meta name="description" content="{{ description | default(value="") }}">
<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;max-width:800px;margin:2rem auto;padding:0 1rem}header time{display:block;color:#666;font-size:0.9rem}</style>
</head>
<body>
<header>
<h1>{{ title }}</h1>
{% if date %}<time>{{ date }}</time>{% endif %}
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer><small>Generado con Rust SSG</small></footer>
</body>
</html>
templates/post.html
{% extends "base.html" %}
{% block content %}
{{ content | safe }}
{% endblock %}
templates/page.html
{% extends "base.html" %}
{% block content %}
{{ content | safe }}
{% endblock %}
content/sample.md
---
title: "Hola Mundo"
date: "2026-01-01"
template: "post.html"
description: "Post de ejemplo"
slug: "hola-mundo"
---
# Hola desde el SSG
Este es un ejemplo mínimo de cómo convertir Markdown en HTML con Rust.
- Soporte para tablas
- Front matter YAML
- Plantillas con Tera
Por qué estas bibliotecas y decisiones
- pulldown-cmark: es rápido, ampliamente usado y tiene buen soporte de opciones (tablas, footnotes).
- tera: motor de plantillas inspirado en Jinja/Django, fácil de usar y con filtros/blocks que facilitan layouts.
- serde + serde_yaml: mapeo directo del front-matter a structs, robusto y explícito.
- rayon: permite paralelizar la conversión de archivos fácilmente para aprovechar CPUs modernos.
- anyhow: manejo de errores sencillo para una CLI; se podría sustituir por errores más estructurados en producción.
Cómo usarlo
- Crear el proyecto:
cargo new rust_ssg, sustituir archivos por los anteriores. - Agregar contenido Markdown en
content/y plantillas entemplates/. - Ejecutar:
cargo run -- --content content --templates templates --output public. - Abrir los HTML generados en
public/.
Extensiones y mejoras (siguientes pasos)
- Agregar modo "watch" con
notifypara reconstrucción automática en cambios. - Soporte para assets (copiar carpeta
static/apublic/). - Implementar incremental build: cachear hashes de archivos Markdown y solo reconstruir los cambiados.
- Generar index y feeds (RSS) recogiendo metadatos de varios posts.
Consejo avanzado: si vas a procesar Markdown con contenido no confiable (por ejemplo, entradas de usuarios), no uses | safe sin sanitizar. Decodifica y filtra HTML peligroso o utiliza una librería de sanitización (ej. crear un pipeline que pase el HTML por un sanitizer). Además, el watch mode y el incremental build requieren manejar correctamente la concurrencia y estado en disco para evitar escrituras parciales; una opción segura es escribir archivos temporales y renombrarlos atómicamente.
Si quieres, en el siguiente paso puedo mostrar cómo añadir watch incremental con notify o cómo empaquetar esto como una herramienta instalable con cargo install.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación