Generador estático de sitios en Rust: SSG mínimo con Tera, pulldown-cmark y Rayon

rust Generador estático de sitios en Rust: SSG mínimo con Tera, pulldown-cmark y Rayon

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

  1. Crear el proyecto: cargo new rust_ssg, sustituir archivos por los anteriores.
  2. Agregar contenido Markdown en content/ y plantillas en templates/.
  3. Ejecutar: cargo run -- --content content --templates templates --output public.
  4. Abrir los HTML generados en public/.

Extensiones y mejoras (siguientes pasos)

  • Agregar modo "watch" con notify para reconstrucción automática en cambios.
  • Soporte para assets (copiar carpeta static/ a public/).
  • 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión