Motor de Blog en Markdown
La web está construida sobre sitios estáticos, una carpeta de publicaciones en texto plano, un paso de renderizado y una pila de archivos HTML que no necesitan servidor, ni base de datos, ni framework de JavaScript para servirse. Este proyecto construye un generador de sitios estáticos en miniatura: lee una carpeta posts/ de archivos Markdown, parsea el frontmatter YAML de cada uno para título/fecha/etiquetas, renderiza el cuerpo a HTML y produce un site/ completo con una página índice y listados de publicaciones filtrados por etiqueta, la misma forma que los motores detrás de mil blogs reales.
Esto asume Python 101, I/O de archivos, cadenas, diccionarios y funciones. Nada más allá de eso: sin framework, sin base de datos, sin servicios externos. Es opcional y no calificado; consulta Proyectos del mundo real para la lista completa.
🎯 Lo que harás
- Diseñar un formato de archivo Markdown-más-frontmatter y dividirlo limpiamente en metadatos y cuerpo.
- Parsear el frontmatter YAML en un dict de Python, un parser diminuto que maneja comillas y listas.
- Renderizar el cuerpo Markdown a HTML con una biblioteca, y escapar cualquier cosa peligrosa.
- Construir una página índice que liste todas las publicaciones, además de páginas filtradas por etiqueta.
- Ejecutar el generador sobre una carpeta de publicaciones reales e inspeccionar el sitio terminado en un navegador.
Dónde ejecutar esto
Localmente con uv es el camino principal aquí. La recompensa de este proyecto es abrir site/index.html en un navegador real, y el bucle “escribe una carpeta de publicaciones, ejecuta un comando, sitio publicado” es más honesto cuando las publicaciones y la salida viven en un sistema de archivos real que puedas tocar.
GitHub Codespaces es un camino igualmente bueno, abre codespaces.new/abderrahim-lectures/python-data-analysis-course y todo lo de abajo, incluida la vista previa del navegador, funciona igual desde una pestaña servida por Cloudflare o python -m http.server. No hay ninguna afirmación de GitHub Pages local aquí, es solo una caja de desarrollo donde los comandos son idénticos.
Google Colab, Kaggle Notebooks y Binder son una forma decente de ver la maquinaria funcionar, pero débiles para la recompensa. El notebook de abajo genera una carpeta posts/ falsa en memoria y renderiza el sitio completo a un directorio que puedes inspeccionar celda por celda, así que el parseo, las plantillas y el ensamblaje se ejecutan honestamente. Lo que no puede hacer bien es el bucle real de tú escribiendo tu propio post.md y refrescando la página; eso es un ejercicio de sistema de archivos más navegador, que es lo que te dan el camino local o el de Codespace. Usa el notebook para aprender los pasos; cambia a uv cuando quieras publicar.
Configuración
Todo lo que necesitas antes de escribir el generador: uv para un Python moderno, una biblioteca Markdown y una carpeta posts/ con dos publicaciones realistas para masticar.
Instala uv y la única dependencia
macOS / Linux (terminal):
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Cierra y vuelve a abrir tu terminal, luego:
uv --version
mkdir markdown-blog-engine && cd markdown-blog-engine
uv init --bare
uv add markdown
Escribe dos publicaciones iniciales
Pega estos en posts/hello.md y posts/python-tips.md:
---
title: "Hello, world from Markdown"
date: "2026-08-03"
tags: "intro, meta"
---
A blog in **Markdown**? Sure. Here is the first post, rendered by *our own* tool.
## Why this exists
We are about to write a static site generator. This paragraph is **bold** on purpose, so the render step has something to do.
---
title: "Three Python tips"
date: "2026-08-04"
tags: "python, tips"
---
1. Use `enumerate` instead of `range(len(...))`.
2. Prefer dicts to parallel lists.
3. **Test** your parser on bad input.
mkdir posts
# save the two blocks above as posts/hello.md and posts/python-tips.md
ls -la posts
✅ Lista de verificación
- ✅
uv --versionimprime un número de versión. - ✅
markdowninstalado víauv add markdown. - ✅
posts/hello.mdyposts/python-tips.mdexisten, cada uno comenzando con un bloque de frontmatter delimitado por---.
Paso 1: Dividir un archivo en frontmatter y cuerpo
Una publicación de sitio estático son realmente dos partes en un archivo: un pequeño bloque YAML de metadatos entre dos líneas ---, y luego el cuerpo Markdown. El primer trabajo del generador es una división limpia y aburrida: hasta el segundo --- es frontmatter, todo lo de después es el cuerpo. Lograr que esta división sea robusta antes de cualquier renderizado elegante es la diferencia entre una herramienta en la que confías y una que silenciosamente suelta publicaciones.
1.1 Escribe el divisor
# engine.py
from pathlib import Path
def read_post(path: str) -> dict:
text = Path(path).read_text(encoding="utf-8")
if not text.startswith("---"):
raise ValueError(f"{path}: no frontmatter block")
lines = text.splitlines()
end = next(i for i, l in enumerate(lines[1:], start=1) if l.strip() == "---")
frontmatter = "\n".join(lines[1:end])
body = "\n".join(lines[end + 1:])
return {"path": path, "frontmatter": frontmatter, "body": body}
if __name__ == "__main__":
for p in sorted(Path("posts").glob("*.md")):
post = read_post(str(p))
print(f"--- {p} ---")
print("frontmatter:", post["frontmatter"].splitlines()[0])
print("body starts:", repr(post["body"].splitlines()[0]))
next((i for i, l in enumerate(...) if ...)) encuentra la línea --- del segundo separador en una sola pasada, el primero lo consume startswith, y todo lo posterior al segundo es el cuerpo. La expresión generadora lanza StopIteration con un archivo malformado, que es un fallo ruidoso y honesto en lugar de basura parcialmente parseada fluyendo silenciosamente río abajo.
👟 Pista inicial : Ejecuta engine.py sobre las dos publicaciones iniciales y confirma que la división coloca las primeras líneas correctas en cada mitad, la primera línea del frontmatter es un título, la primera línea del cuerpo es prosa.
🎯 Resultado esperado : Para cada publicación, una línea que muestre una primera línea de frontmatter como title: "Hello, world from Markdown" y una que muestre una primera línea de body como 'A blog in **Markdown**? Sure. ...'.
🩹 Si sale mal : Si StopIteration sube como traceback, a una publicación le falta su --- de cierre, añádelo (la división debe ver un segundo separador). Si el cuerpo incluye el --- de cierre, tu índice end está desviado por uno, revisa que lines[end + 1:] empiece después de esa línea, no en ella.
1.2 Verifica la división
✅ Lista de verificación
- ✅ Ambas publicaciones iniciales se dividen en una cadena de frontmatter y una de cuerpo sin que ninguna línea
---se filtre en ninguna de las dos. - ✅ Quitar el
---inicial de un archivo de publicación hace queread_postlance unValueErrorclaro con el nombre del archivo. - ✅ Puedes predecir qué devuelve
read_postpara un archivo con tres líneas---(la división usa la segunda; la tercera se convierte en cuerpo).
🤔 Pregunta(s) socrática(s)
- Encontramos el
---de cierre escaneando una línea que sea exactamente---. ¿Qué pasaría con una línea del cuerpo que en sí misma sea---? ¿El modo de fallo es una división incorrecta silenciosa o una ruidosa, y cuál preferirías? - El divisor devuelve la cadena de frontmatter cruda. ¿Qué implica eso para el caso límite de cadena vacía donde dos publicaciones juntas tienen líneas en blanco sueltas, y dónde en la canalización crees que debería ocurrir el parseo?
Paso 2: Parsear el frontmatter YAML
Ahora la cadena de frontmatter se convierte en un dict real, title, date, tags, para que el resto del motor pueda hacer post["title"] en lugar de re-parsear texto. YAML es una madriguera de conejo; un generador solo necesita las ~4 reglas que cubren nuestros propios archivos: key: value, valores entre comillas con dos puntos, y listas separadas por comas.
2.1 Escribe un parser de subconjunto de YAML
# engine.py (continued)
def parse_frontmatter(raw: str) -> dict:
data = {}
for line in raw.splitlines():
if not line.strip():
continue
key, value = line.split(":", 1)
value = value.strip()
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
elif "," in value:
value = [v.strip() for v in value.split(",")]
elif not value:
value = []
data[key.strip()] = value
return data
if __name__ == "__main__":
for p in sorted(Path("posts").glob("*.md")):
meta = parse_frontmatter(read_post(str(p))["frontmatter"])
print(p, "->", meta)
line.split(":", 1) es la línea que hace esto seguro: dividir una vez mantiene intactos los dos puntos dentro del valor (como 08:30 o https://...), porque la segunda parte no se vuelve a dividir. El valor toma entonces una de tres formas, cadena sin comillas, cadena entre comillas con las comillas quitadas, o lista de comas, que es todo el subconjunto de YAML que prometimos.
👟 Pista inicial : Imprime el dict parseado para ambas publicaciones antes de escribir una sola línea del renderizador, quieres ver que tags se convierta en una lista, no en una cadena.
🎯 Resultado esperado : Dos líneas como posts/hello.md -> {'title': 'Hello, world from Markdown', 'date': '2026-08-03', 'tags': ['intro', 'meta']}, nota que tags es una lista real.
🩹 Si sale mal : Si title conserva sus comillas, la rama de quitar comillas startswith/endswith no está coincidiendo, revisa si hay un espacio al final después de la comilla de cierre en el archivo (hacemos strip() de las comillas pero el valor ya fue recortado). Si tags sale como una sola cadena 'intro, meta', la verificación "," in value ocurrió antes del recorte, el orden importa: recorta primero, luego ramifica.
2.2 Verifica el parseo de YAML
✅ Lista de verificación
- ✅
parse_frontmatterdevuelve un dict dondetagses unalistytitlees una cadena desnuda sin comillas. - ✅ Un valor como
date: "2026-08-04"se parsea a'2026-08-04'con las comillas eliminadas. - ✅ Una línea de frontmatter sin su valor (
author:) produce una lista vacía, y puedes explicar por qué se elige[]sobreNone.
🤔 Pregunta(s) socrática(s)
- Nuestro parser no puede manejar una lista anidada ni un bloque
oneline: | .... Escribe el frontmatter más pequeño que se malparsearía silenciosamente, y decide si eso es aceptable para un motor de blog personal (pista: nombra el fallo como ruidoso vs. silencioso). - Un parser YAML real (como
PyYAML, la biblioteca que usan las herramientas reales) soporta anclas, cadenas multilínea y 100 características más. ¿Cuál es el costo de arrastrar eso a un proyecto cuyos archivos controlas? ¿Cuándo “solo instala PyYAML” se vuelve la decisión correcta?
Paso 3: Renderizar Markdown a HTML
Parsear produce texto; renderizar produce una página. La biblioteca markdown convierte **bold**, # heading y código delimitado en etiquetas <strong>, <h1> y <pre>. Hay una arruga de seguridad en el HTML que sale, el cuerpo podría contener HTML crudo, y uno hostil puede llevar JavaScript. El arreglo probado y confiable, bleach, puede que ya esté en tu wheel. Así que el renderizador hace dos trabajos: convertir, y luego sanitizar.
3.1 Convierte y sanitiza
# render.py
from pathlib import Path
try:
from bleach import clean
except ImportError:
def clean(text: str, **kwargs) -> str:
return text
import markdown as md
def to_html(body: str) -> str:
raw = md.markdown(body, extensions=["fenced_code", "tables"])
return clean(raw, tags={"p", "h1", "h2", "h3", "em", "strong", "code",
"pre", "ul", "ol", "li", "blockquote", "img",
"a", "table", "thead", "tbody", "tr", "td", "th"},
attributes={"a": {"href", "title"}, "img": {"src", "alt"}})
if __name__ == "__main__":
body = "**Bold here** with <script>alert('x')</script> and `code`."
print(to_html(body))
bleach es la mentalidad de lista blanca en acción: en lugar de intentar atrapar cada cosa maliciosa (un juego perdido), declaras exactamente qué etiquetas y atributos pueden sobrevivir, y todo lo demás, el <script>, se descarta. El try/except del import es deliberado: el código corre incluso en una instalación limpia, degradando a sin sanitización, e imprime un fallback sin advertencias para que el notebook y la instalación completa compartan un archivo.
👟 Pista inicial : Instala bleach con uv add bleach, luego ejecuta render.py y confirma que la etiqueta <script> desaparece de la salida mientras **Bold** se convirtió en <strong>.
🎯 Resultado esperado : HTML donde <strong>Bold here</strong> está presente y <script>/alert(...) están completamente ausentes, las etiquetas de script eliminadas por la lista blanca.
🩹 Si sale mal : Si <script> todavía aparece en la salida, estás golpeando el fallback degradado de clean, revisa que uv add bleach tuvo éxito y la ruta de import (from bleach import clean) es correcta. Si el bold no renderizó, md.markdown con extensions=["fenced_code", "tables"] se está llamando sobre la cadena de cuerpo que todavía tiene frontmatter, asegúrate de que read_post lo haya dividido antes.
3.2 Verifica el renderizado
✅ Lista de verificación
- ✅
to_html("**x**")devuelve HTML que contiene<strong>x</strong>. - ✅
to_html("<script>...")devuelve HTML sin<script>,<iframe>ni atributosonclick=. - ✅ Los bloques de código delimitados (
```python) sobreviven al renderizado como<pre>/<code>.
🤔 Pregunta(s) socrática(s)
- Eliminamos el HTML crudo después de la conversión de Markdown. La mayoría de los motores de Markdown reales pasan el HTML crudo intacto, por eso
md+ un sanitizador es el orden de “convertir, luego lista blanca” con doble seguridad. ¿Qué ataque sobreviviría si sanitizaras antes de la conversión (pista: cada<en un bloque de código es significativo para el conversor)? - La lista blanca mantiene
imgpero solo los atributossrc/alt. ¿Cuál es el riesgo concreto si añadierasonerrora los atributos permitidos, escribe el HTML de una línea que lo dispare.
Paso 4: Ensambla las páginas
Ahora el generador se gana la palabra “sitio”: cada publicación se convierte en su propio archivo .html, y las páginas índice/etiqueta se derivan de las publicaciones. La derivación es el truco central de la generación estática, nunca escribes el índice a mano; lo calculas en cada ejecución, así que “añade una publicación, vuelve a ejecutar, el índice se actualiza” siempre es verdad.
4.1 Construye la plantilla de página y el escritor
# sitegen.py
from pathlib import Path
from engine import read_post, parse_frontmatter
from render import to_html
PAGE = """<!doctype html>
<html><head><meta charset="utf-8">
<title>{title}</title></head>
<body>
<header><a href="index.html">All posts</a></header>
<h1>{title}</h1>
<p class="meta">{date} · {tags}</p>
<article>{body_html}</article>
<footer><p><a href="index.html">← back to index</a></p></footer>
</body></html>"""
def build_post(post_path: str, out_dir: Path) -> dict:
raw = read_post(post_path)
meta = parse_frontmatter(raw["frontmatter"])
meta.setdefault("title", "Untitled")
meta.setdefault("date", "unknown")
tags = ", ".join(meta.get("tags", []))
html = PAGE.format(title=meta["title"], date=meta["date"],
tags=tags, body_html=to_html(raw["body"]))
out = out_dir / f"{Path(post_path).stem}.html"
out.write_text(html, encoding="utf-8")
return {"slug": Path(post_path).stem, "title": meta["title"],
"date": meta["date"], "tags": meta.get("tags", [])}
if __name__ == "__main__":
out = Path("site")
out.mkdir(exist_ok=True)
posts = sorted((build_post(str(p), out) for p in Path("posts").glob("*.md")),
key=lambda d: d["date"], reverse=True)
print("built:", [p["slug"] for p in posts])
PAGE es una plantilla diminuta con ranuras {name} rellenadas por .format(), modelo, vista y controlador aplastados en una sola cadena, que es suficiente para un generador de este tamaño. El orden por fecha (más reciente primero) es la primera vista que depende de los metadatos, y el valor de retorno de build_post, no el archivo que escribió, es lo que consumirá la página índice, así que el índice nunca re-parsea los archivos dos veces.
👟 Pista inicial : Ejecuta sitegen.py, luego open site/hello.html (o start/xdg-open en tu sistema operativo) y mira una publicación renderizada real antes de construir el índice.
🎯 Resultado esperado : built: ['python-tips', 'hello'] (más reciente primero, python-tips fecha 2026-08-04) y dos archivos .html de tamaño manual bajo site/ que se renderizan en un navegador con título, línea de meta y cuerpo de artículo.
🩹 Si sale mal : Si se dispara KeyError: 'title', al frontmatter de una publicación le falta title, las llamadas setdefault en build_post existen para absorber eso; si ves el error, los setdefaults se colocaron después de un .format que ya corrió. Si site/ acumula páginas obsoletas de publicaciones eliminadas, eso es lo esperado por ahora: limpia site/ antes de cada build, o llámalo una característica y elimina a mano.
4.2 Construye el índice con enlaces por etiqueta
# sitegen.py (continued)
def build_index(posts: list[dict], out_dir: Path) -> None:
items = "\n".join(
f'<li><a href="{p["slug"]}.html">{p["title"]}</a> '
f'<small>({p["date"]})</small></li>' for p in posts)
(out_dir / "index.html").write_text(
f"""<!doctype html><html><head><meta charset="utf-8"><title>My blog</title></head>
<body><h1>My blog</h1><ul>{items}</ul>
<p>Tags: {tags_block(posts)}</p></body></html>""", encoding="utf-8")
def tags_block(posts: list[dict]) -> str:
by_tag = {}
for p in posts:
for t in p["tags"]:
by_tag.setdefault(t, []).append(p["slug"])
return " ".join(f'<a href="tag-{t}.html">{t}</a>' for t in sorted(by_tag))
if __name__ == "__main__":
# ...build_post loop as above, then:
build_index(posts, out) # referenced 'posts' from the previous block
print("index written")
by_tag.setdefault(t, []).append(...) es el modismo “construye un dict de listas” en una línea, la alternativa if t not in by_tag: by_tag[t] = [] es lo mismo escrito explícitamente. El índice está enteramente derivado: contiene cero HTML escrito a mano, así que nunca puede discrepar con la carpeta de publicaciones. Ese invariante es toda la razón por la que la generación estática vence al mantenimiento manual de un índice.
👟 Pista inicial : Añade build_index y tags_block, vuelve a ejecutar, luego abre index.html y haz clic en un enlace de etiqueta, lee el 404 antes de arreglarlo; verás exactamente lo que el siguiente micro-paso debe crear.
🎯 Resultado esperado : site/index.html lista ambas publicaciones de más reciente a más antigua, muestra una línea “Tags:” con intro, meta, python, tips enlazando a tag-intro.html etc., y la propia página de cada publicación enlaza de vuelta al índice.
🩹 Si sale mal : Si un enlace de etiqueta da 404, ese es el comportamiento correcto, las páginas destino aún no existen, y el Paso 5 es específicamente la generación de tag-*.html. Si el índice muestra las publicaciones en el orden equivocado, el sorted(..., key=lambda d: d["date"], reverse=True) debe ejecutarse sobre la lista recopilada antes de build_index, no después.
4.3 Verifica el ensamblaje
✅ Lista de verificación
- ✅
site/hello.htmlysite/python-tips.htmlse abren en un navegador con título, meta y cuerpo renderizado reales. - ✅
index.htmllista ambas publicaciones de más reciente a más antigua y apunta a archivos.htmlexistentes (los enlaces de etiqueta pueden dar 404 hasta el Paso 5). - ✅ Re-ejecutar el build después de editar una publicación produce HTML actualizado, el índice y las páginas nunca discrepan con
posts/.
🤔 Pregunta(s) socrática(s)
build_indexrecibe una lista de dicts en lugar de volver a leer el sistema de archivos. ¿Qué se rompe, concretamente, si en su lugar re-parsearaposts/*.mda sí mismo? (Pista: dos fuentes de verdad y una inconsistencia de ordenamiento.)- La página índice y la página de etiquetas dependen ambas de
posts. Si una publicación tiene etiquetas["a", "b"], el índice las une con una coma pero la página de etiquetas las agrupa. Nombra un lugar donde estas dos derivaciones podrían divergir, y qué regla las mantendría idénticas.
Paso 5: Genera las páginas por etiqueta
El índice es una vista derivada; una página que “muestra solo las publicaciones con la etiqueta X” es una vista derivada filtrada. El bucle que escribe una página por etiqueta tiene la misma forma que toda herramienta que “genera un artefacto por elemento de una colección”, una plantilla por elemento con el elemento sustituido dentro.
5.1 Escribe las páginas de etiquetas
# sitegen.py (continued)
def build_tag_pages(posts: list[dict], out_dir: Path) -> None:
by_tag = {}
for p in posts:
for t in p["tags"]:
by_tag.setdefault(t, []).append(p)
for tag, tagged in sorted(by_tag.items()):
items = "\n".join(
f'<li><a href="{p["slug"]}.html">{p["title"]}</a></li>'
for p in tagged)
(out_dir / f"tag-{tag}.html").write_text(
f"""<!doctype html><html><head><meta charset="utf-8"><title>tag: {tag}</title></head>
<body><h1>Posts tagged "{tag}"</h1><ul>{items}</ul>
<p><a href="index.html">← index</a></p></body></html>""",
encoding="utf-8")
if __name__ == "__main__":
build_tag_pages(posts, out)
print("tag pages written:", sorted(t for t in Path("site").glob("tag-*.html")))
La agrupación aquí es setdefault de nuevo, el mismo modismo del Paso 4, ahora retenido por etiqueta en tagged, que es una lista de dicts de publicación, no de slugs, para que la plantilla tenga a mano el título y el slug. Cada página de etiqueta es un <li> por publicación, exactamente como el índice menos la fecha y menos toda publicación que no coincida.
👟 Pista inicial : Vuelve a ejecutar el build y haz clic en cada enlace de etiqueta del índice, este paso convierte cada 404 anterior en una página real.
🎯 Resultado esperado : tag-intro.html, tag-meta.html, tag-python.html, tag-tips.html existen bajo site/, cada uno listando las publicaciones coincidentes, y cada enlace de etiqueta del índice ahora se resuelve.
🩹 Si sale mal : Si una página de etiqueta contiene publicaciones equivocadas, la agrupación añadió p, el dict completo, mientras items se construye desde p["slug"]; una agrupación incorrecta significa que agrupaste por una copia obsoleta de posts. Si una etiqueta sin publicaciones muestra un <ul> vacío, construiste by_tag desde una lista de posts vacía, re-visa que build_tag_pages se ejecute después de que posts se recolecte.
5.2 Verifica el sitio terminado
✅ Lista de verificación
- ✅ Cada enlace de
index.html, publicaciones y etiquetas, se resuelve a un archivo existente. - ✅
tag-python.htmllistaThree Python tipsy noHello, world. - ✅
site/contiene exactamente:hello.html,python-tips.html, untag-*.htmlpor etiqueta distinta, yindex.html.
🤔 Pregunta(s) socrática(s)
- La plantilla de la página de etiquetas repite la del índice con dos diferencias. Funciona, pero ¿cuándo refactorizarías ambas en una sola
post_list_page(title, posts)compartida? Nombra el olor concreto que dispara la refactorización. - Escribimos
tag-{tag}.htmlcon una cadena de etiqueta cruda de un frontmatter no confiable. Si la etiqueta de una publicación fuera../evil, ¿en qué se convierte la ruta del archivo, y cuál es la sanitización mínima que añadirías antes de usar cualquier etiqueta en un nombre de archivo? (Pista: piensa enslugify.)
⚠️ Errores comunes
- La búsqueda del segundo
---desviada por uno. Tantonext(...)como el slicelines[end + 1:]deben coincidir sobre cuál línea es “el” separador; un desliz de una línea adjunta silenciosamente el---de cierre al cuerpo, que el renderizador de Markdown luego renderiza felizmente como un<hr>. Arrégialo afirmando en un pequeño test quebodynunca empieza con---. - Frontmatter sin cerrar. Una publicación que estabas editando a medias se guarda sin su
---de cierre; el generador entonces no puede encontrar la división y muere con un traceback críptico. Verificar que la división existe desde el principio, y lanzarValueErrorcon el nombre del archivo, convierte un misterio de 30 minutos en un arreglo de dos segundos. - Sanitizar o renderizar, no ambos. Renderizar a HTML sin pasar por
bleachdeja que unpost.mdlleve<script>a los navegadores de tus visitantes; sanitizar sin renderizar deja el Markdown visible como texto crudo. El orden de doble seguridad (convertir, luego lista blanca) es todo el punto del Paso 3, los motores reales también lo hacen mal. - Dos fuentes de verdad. Editar a mano
index.html“solo para arreglar una cosa” mientras el generador aún lo deriva deposts/garantiza que tu próximo build sobrescriba silenciosamente la edición. Regla: el sitio se genera, nunca se mantiene a mano, cada artefacto debe ser reproducible solo desde la carpeta de publicaciones. - Cobertura de test faltante en la “canalización completa”. Cada paso pasa solo, pero una publicación cuyo frontmatter dice
date: "2026-08-04"con un espacio después de la llave, o una etiqueta con mayúscula, es donde el paso de ensamblaje rompe todo el build. Un smoke test de dos líneas (build, luego afirmar que cada archivo generado existe y cada<a href>se resuelve) atrapa esa clase de fallo antes de que publiques.
Lo que acabas de construir
Un generador de sitios estáticos que funciona: dos publicaciones entran, un comando, y una carpeta site/ de HTML legible a mano, páginas, índice y listados por etiqueta, todos derivados de las publicaciones para que el build nunca pueda discrepar con la fuente. La habilidad transferible es todo el modelo mental de generación estática: una canalización pequeña y pura (parsear → renderizar → ensamblar) que convierte archivos de texto plano en un artefacto desplegable que puedes alojar en cualquier parte, desde una carpeta sobrante hasta un CDN, con nada ejecutándose en el momento de la solicitud. Ese modelo es lo que impulsa a Jekyll, Hugo, Gatsby y mil blogs personales, y ahora es tuyo.
Ejecuta una versión más completa sin configuración local
examples/markdown-blog-engine/ en el repositorio del curso agrupa el motor, ambas publicaciones de muestra y un notebook que ejecuta cada paso en orden. Clónalo, o abre el repositorio completo en un GitHub Codespace, e inspecciona el site/ generado justo en el árbol.
A dónde ir desde aquí
- Añade un feed RSS, un archivo XML que liste el título, enlace y fecha de cada publicación, regenerado en cada build; el hábito de derivar de las publicaciones hace que eso sea una adición de 15 líneas.
- Añade estimaciones de tiempo de lectura, cuenta las palabras del cuerpo, divide por ~200, redondea hacia arriba, y muestra “4 min read” en el índice; el contador es una línea, el templating es la parte divertida.
- Escribe un archivo consciente de la fecha (
archive-2026.html) agrupado por año, la agrupación exacta desetdefaultdel Paso 5, una llave más. - Despliega: sube
site/a un repositorio de GitHub Pages (o a una sola rama) y deja que un host web gratuito lo sirva, todo el punto del modelo estático es que la salida se puede enviar con cero partes móviles.
Comparte tu proyecto con la clase
¿Construiste algo de lo que te sientas orgulloso, un motor de blog que renderizó tus propias publicaciones? examples/student-projects/ es una galería de proyectos que otros estudiantes han enviado, y su README te guía para añadir el tuyo mediante una pull request de principio a fin: fork, rama, commit y apertura de la PR. No se asume ninguna experiencia previa con git.
Bienvenido a escribir Python fuera del navegador. 🎓
Termina todos los pasos y luego marca este proyecto como completo para ganar su XP.