commit 651bd55ef3d0ff3c54a464e5ca690ca86ac8be17
Author: Juan <juan@fedi.ovh>
Date: Mon, 18 May 2026 10:59:52 +0200
initial commit: simpleblog v0
generador estatico minimalista de blog escrito en sh + smu.
caracteristicas:
- posts y paginas en markdown (smu) o html crudo, con frontmatter
- borradores (draft: true), slug custom, tags
- index paginado, paginas por tag, indice global de tags
- feed atom
- imagenes: redimensionado por tamaños + variantes webp con cache mtime
- menu del sitio desde pages con menu: true / menu_order
dependencias: sh, awk, sed, make, ImageMagick (opcional, solo imagenes).
smu se clona y compila con `make` (vendored en vendor/smu/, gitignored).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Diffstat:
26 files changed, 1086 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,10 @@
+# salida generada
+public/
+
+# dependencias vendored (se clonan + compilan con `make`)
+vendor/
+
+# basura comun
+*.o
+*.swp
+.DS_Store
diff --git a/Makefile b/Makefile
@@ -0,0 +1,47 @@
+.POSIX:
+
+PREFIX = /usr/local
+BIN = simpleblog
+OUT = public
+PORT = 8080
+
+all: build
+
+build: vendor/smu/smu
+ ./simpleblog build
+
+clean:
+ ./simpleblog clean
+
+stats:
+ ./simpleblog stats
+
+vendor/smu/smu: vendor/smu
+ cd vendor/smu && $(MAKE)
+
+vendor/smu:
+ mkdir -p vendor
+ git clone --depth=1 https://github.com/Gottox/smu.git vendor/smu
+
+# httpd minimo (busybox o python -m http.server como fallback)
+serve: build
+ @if command -v busybox >/dev/null 2>&1; then \
+ echo "serving $(OUT)/ on http://localhost:$(PORT)"; \
+ busybox httpd -f -p $(PORT) -h $(OUT); \
+ else \
+ echo "serving $(OUT)/ on http://localhost:$(PORT) (python)"; \
+ cd $(OUT) && python3 -m http.server $(PORT); \
+ fi
+
+install: build
+ mkdir -p $(DESTDIR)$(PREFIX)/bin
+ cp -f $(BIN) $(DESTDIR)$(PREFIX)/bin/$(BIN)
+ chmod 755 $(DESTDIR)$(PREFIX)/bin/$(BIN)
+
+uninstall:
+ rm -f $(DESTDIR)$(PREFIX)/bin/$(BIN)
+
+distclean: clean
+ cd vendor/smu && $(MAKE) clean
+
+.PHONY: all build clean serve stats install uninstall distclean
diff --git a/config b/config
@@ -0,0 +1,29 @@
+# simpleblog config — key: value, lineas con # son comentarios
+title: simpleblog
+description: Un blog minimalista
+author: Juan
+# baseurl: path para enlaces relativos (acaba en /)
+baseurl: /
+# siteurl: URL absoluta, sin / final. Usado para el feed atom.
+siteurl: https://example.com
+lang: es
+# nombre del feed (vacio para no generarlo)
+feed: atom.xml
+
+# paginacion del index. 0 = sin paginar (todos los posts en index.html)
+posts_per_page: 2
+
+# anchos (px) a generar para cada imagen en images/. coma-separados.
+# vacio = no procesar imagenes (solo copiar al output).
+# en posts referencias /img/<nombre>-<ancho>.<ext>
+image_sizes: 320, 640, 1280
+# calidad jpeg/webp (1-100). 85 = compromiso bueno.
+image_quality: 85
+# formatos extra a generar ademas del original (coma-separados, sin punto).
+# webp pesa ~30% menos que jpeg. avif aun menos pero mas lento de generar.
+# vacio = no generar extras.
+image_extra_formats: webp
+
+# comando para convertir markdown -> html (lee stdin, escribe stdout)
+# rutas que empiezan por ./ se resuelven respecto al directorio del proyecto
+markdown_cmd: ./vendor/smu/smu
diff --git a/images/ejemplo.jpg b/images/ejemplo.jpg
Binary files differ.
diff --git a/objetivo.txt b/objetivo.txt
@@ -0,0 +1,145 @@
+simpleblog — generador estatico minimalista de blog
+====================================================
+
+OBJETIVO
+--------
+- Blog simple y minimalista. Minimo espacio, memoria y dependencias.
+- Estructura limpia, etiquetas semanticas (head, header, main, article, footer).
+- Versatil y facilmente configurable.
+- Listado de entradas en la pagina principal con fecha + titulo + descripcion.
+- Cabecera personalizable, css simple, logo, favicon, titulo, descripcion.
+- Entradas en markdown (con posibilidad de html plano dentro).
+- Inspirado en saait (codemadness.org/saait.html) pero comiendo .md.
+
+
+DECISIONES (2026-05-18)
+-----------------------
+- Lenguaje: shell script POSIX (sh + awk + sed).
+ Razon: minimo absoluto, sin compilar, facil de iterar. Si se queda corto,
+ migramos a C reusando saait.
+- Conversor markdown: smu (suckless), clonado y compilado en vendor/smu/.
+ El comando se configura en `config` (variable `markdown_cmd`); rutas que
+ empiezan por `./` se resuelven respecto al directorio del proyecto.
+ Nota: smu solo entiende markdown clasico de Gruber, NO triple backtick.
+ Bloques de codigo = 4 espacios de indentacion.
+- Metadatos: frontmatter dentro del propio .md, formato `key: value`, separado
+ del cuerpo por una linea en blanco. Un fichero por entrada, sin .cfg aparte.
+- Plantillas: ficheros HTML con `{{var}}` sustituido por sed/awk. Sin parser
+ propio.
+- Estilo: estructura semantica + css minimal.
+
+
+ESTRUCTURA
+----------
+simpleblog/
+ simpleblog script principal (sh)
+ Makefile build / clean / serve / install
+ config config global (key: value)
+ posts/ entradas .md|.html con frontmatter
+ pages/ paginas estaticas (about, contacto...)
+ images/ imagenes originales (alta resolucion)
+ templates/
+ header.html <head>, <header>, nav del menu
+ footer.html <footer>
+ post.html entrada individual (con tags y fecha)
+ page.html pagina estatica individual
+ index.html listado de posts (reusado para tag pages)
+ index-item.html item de la lista
+ tags.html indice global de tags
+ atom-header.xml cabecera del feed atom
+ atom-item.xml entry del feed atom
+ atom-footer.xml pie del feed atom
+ static/ se copia tal cual a public/ (css, favicon, logo, imgs)
+ vendor/smu/ conversor markdown vendored
+ public/ salida generada (gitignorable)
+
+
+USO
+---
+make build genera public/ (compila smu la primera vez)
+make clean borra public/
+make serve sirve public/ con busybox httpd en :8080 (fallback python)
+make stats resumen de tamanos (originales, salida, variantes...)
+make distclean borra public/ y limpia smu
+
+
+HECHO (2026-05-18)
+------------------
+[x] Estructura de carpetas
+[x] Script `simpleblog` (build + clean)
+[x] Parseo de frontmatter en .md
+[x] Conversion md -> html via comando externo configurable
+[x] Plantillas header / footer / post / index / index-item con sustitucion {{var}}
+[x] Index ordenado por fecha descendente con titulo + fecha + descripcion
+[x] Copia de static/ tal cual al output
+[x] CSS minimal de partida
+[x] Post de ejemplo (posts/2026-05-18-hola-mundo.md) renderiza correctamente
+[x] Integrar smu vendored (vendor/smu/) y compilar via Makefile
+[x] Bloques de codigo renderizan como <pre><code> con escapado correcto
+[x] Makefile con build / clean / serve / install / distclean
+[x] Feed atom (atom.xml) generado, con plantillas atom-header/item/footer
+[x] siteurl en config para URLs absolutas del feed
+[x] <link rel="alternate"> al feed en el <head> de cada pagina
+[x] XML del feed validado con xmllint
+[x] Soporte para entradas .html (saltarse smu, usar cuerpo tal cual). El
+ bucle de posts itera sobre *.md y *.html. Frontmatter funciona igual.
+[x] Borradores: frontmatter `draft: true` salta el post y avisa en stdout
+[x] Slug en frontmatter: `slug: foo` -> public/foo.html (anula el nombre
+ del fichero, util para que la URL sea estable independiente del nombre)
+[x] favicon.svg y logo.svg minimos en static/, enlazados desde header.html
+ (favicon como <link rel="icon" type="image/svg+xml">, logo dentro del
+ .site-brand junto al titulo)
+[x] Paginas estaticas en `pages/` (paralelo a posts/). No aparecen en
+ index ni feed. Frontmatter `menu: true` + `menu_order: N` las anade
+ al menu del sitio (nav en header.html, ordenado numerico).
+[x] Paginacion del index: config `posts_per_page`. index.html lleva los
+ mas recientes y page/2.html, page/3.html... el resto. Enlaces
+ prev/next pre-renderizados a HTML por el script.
+[x] Tags en posts: frontmatter `tags: foo, bar`. Cada post muestra sus
+ tags. Se genera una pagina por tag (public/tags/<tag>.html) y un
+ indice global (public/tags.html) con cuenta de posts por tag.
+[x] Refactor del script: funciones `sub_var` / `sub_file` / `render_layout`
+ eliminan duplicacion entre renders de post / page / index / tag-page.
+[x] Imagenes: carpeta images/ con originales, config define
+ image_sizes (lista de anchos en px) e image_quality. El build genera
+ public/img/<nombre>-<ancho>.<ext> por cada (imagen, ancho) y copia
+ el original. En markdown usas `` directamente.
+ Sin upscale (-resize "Wx>" de magick). Cache por mtime: la segunda
+ build solo procesa imagenes nuevas/modificadas. Strip de EXIF.
+ Fallback: si magick/convert no estan, copia images/ tal cual.
+[x] Formatos extra de imagen: config `image_extra_formats: webp` (o avif).
+ Para cada (imagen, ancho) se genera tambien la variante en el formato
+ extra. WebP comprime ~70% menos que JPEG en pruebas reales.
+[x] Comando `make stats`: resumen de tamanos (originales, salida total,
+ variantes de imagen) y conteos (posts, pages, tags).
+
+
+POR HACER (pendiente, priorizado)
+[ ] .gitignore para public/ y vendor/smu/*.o, smu binario
+[ ] Repo git inicial + commit
+[ ] Ocultar <nav class="pagination"> cuando esta vacia (en tag pages y
+ cuando hay una sola pagina) — ahora queda un nav vacio en el HTML
+[ ] Validacion: avisar si un slug colisiona entre posts/pages/borradores
+[ ] Sanitizar tags (caracteres invalidos en nombre de fichero)
+
+GOTCHAS / NOTAS
+---------------
+- smu NO entiende triple backtick. Bloques de codigo = 4 espacios de
+ indentacion (markdown clasico).
+- El feed atom mete el HTML del post dentro de CDATA. Si un post contiene
+ literalmente `]]>` se rompera el XML; cuando aparezca lo solucionamos.
+- siteurl en `config` no lleva / al final; baseurl si (o empieza por /).
+ Las URLs absolutas del feed se forman como `$siteurl$baseurl<path>`.
+- Colision de nombres: si tienes `foo.md` y `foo.html` en posts/, el segundo
+ procesado sobreescribe al primero en public/. Lo mismo entre posts/ y
+ pages/ si comparten slug. Usa nombres / slugs distintos.
+- Tags: los usamos tal cual para nombre de fichero (public/tags/<tag>.html).
+ Si una tag tiene espacios, barras o caracteres raros, fallara. Usa solo
+ [a-z0-9-_] por ahora.
+- En las paginas de tag y cuando solo hay una pagina de index, el
+ <nav class="pagination"> queda vacio (con espacios). No rompe nada
+ visualmente pero el HTML queda con un nodo vacio.
+- La cache de imagenes vive en public/img/. `make clean` borra public/
+ entera, por lo que tras un clean se regeneran TODAS las imagenes.
+ Para iterar rapido, usa `make build` directamente (sin clean) — el
+ check de mtime salta imagenes no modificadas.
diff --git a/pages/about.md b/pages/about.md
@@ -0,0 +1,12 @@
+title: Sobre el sitio
+description: Quien soy y de que va este blog.
+menu: true
+menu_order: 10
+
+# Sobre el sitio
+
+Esta es una pagina estatica fuera de `posts/`. No aparece en el listado
+del blog ni en el feed, pero se enlaza desde el menu de la cabecera.
+
+Si el frontmatter tiene `menu: true`, la pagina aparece en el menu del
+sitio. El orden viene del campo `menu_order` (numerico).
diff --git a/pages/contacto.md b/pages/contacto.md
@@ -0,0 +1,8 @@
+title: Contacto
+description: Como ponerse en contacto.
+menu: true
+menu_order: 20
+
+# Contacto
+
+Aqui iria un correo, una direccion de mastodon, lo que sea.
diff --git a/posts/2026-05-18-hola-mundo.md b/posts/2026-05-18-hola-mundo.md
@@ -0,0 +1,25 @@
+title: Hola mundo
+date: 2026-05-18
+description: La primera entrada del blog, comprobando que el generador funciona.
+tags: meta, simpleblog
+
+# Hola mundo
+
+Esta es la **primera entrada** de simpleblog. Si la estas leyendo en HTML,
+el conversor de markdown y las plantillas funcionan.
+
+## Que hay aqui
+
+* Listas
+* *Cursiva* y **negrita**
+* `codigo inline`
+
+Bloque de codigo (cuatro espacios de indentacion, estilo markdown clasico):
+
+ #include <stdio.h>
+ int main(void) {
+ puts("hola mundo");
+ return 0;
+ }
+
+Y un [enlace](https://codemadness.org/saait.html) al proyecto que nos inspira.
diff --git a/posts/2026-05-18-pagina-en-html.html b/posts/2026-05-18-pagina-en-html.html
@@ -0,0 +1,24 @@
+title: Pagina en HTML
+date: 2026-05-18
+description: Para casos donde necesito HTML crudo sin pasar por markdown.
+tags: html, ejemplo
+
+<p>Esta entrada esta escrita directamente en <strong>HTML</strong>. El frontmatter sigue
+siendo el mismo (lineas <code>key: value</code> hasta la primera linea en blanco).</p>
+
+<p>Util para incrustar cosas que smu no soporta:</p>
+
+<table>
+ <thead>
+ <tr><th>col 1</th><th>col 2</th></tr>
+ </thead>
+ <tbody>
+ <tr><td>a</td><td>1</td></tr>
+ <tr><td>b</td><td>2</td></tr>
+ </tbody>
+</table>
+
+<figure>
+ <img src="/logo.png" alt="logo placeholder">
+ <figcaption>Pie de imagen</figcaption>
+</figure>
diff --git a/posts/2026-05-19-borrador.md b/posts/2026-05-19-borrador.md
@@ -0,0 +1,6 @@
+title: Este post es un borrador
+date: 2026-05-19
+description: No deberia aparecer en el blog publicado.
+draft: true
+
+Si estas viendo esto en `public/`, algo no funciona. Los borradores se saltan.
diff --git a/posts/2026-05-20-con-slug.md b/posts/2026-05-20-con-slug.md
@@ -0,0 +1,11 @@
+title: Entrada con slug personalizado
+date: 2026-05-20
+description: La URL es /sobre-slugs.html, no la del nombre de fichero.
+slug: sobre-slugs
+tags: meta
+
+El campo `slug` en el frontmatter sobrescribe el nombre del fichero.
+
+Asi el nombre de fichero puede llevar la fecha para ordenarlo en `posts/`,
+pero la URL publica queda limpia y estable: si cambio el titulo o la fecha,
+el slug se queda como esta.
diff --git a/posts/2026-05-21-imagenes.md b/posts/2026-05-21-imagenes.md
@@ -0,0 +1,50 @@
+title: Imagenes en simpleblog
+date: 2026-05-21
+description: Como insertar imagenes redimensionadas automaticamente.
+tags: imagenes, simpleblog
+
+# Imagenes
+
+Las imagenes originales viven en `images/` (alta resolucion). El build
+genera variantes en `public/img/` para los anchos definidos en `config`
+(`image_sizes`).
+
+Para usarlas en un post enlazas la URL con el sufijo del ancho:
+
+Imagen a 640 px de ancho:
+
+
+
+La misma a 320 px:
+
+
+
+Y a 1280:
+
+
+
+Original (sin sufijo, tal cual):
+
+
+
+## Variantes WebP
+
+Si en `config` defines `image_extra_formats: webp`, se generan tambien
+variantes WebP (pesan ~30-75% menos). Tres formas de usarlas:
+
+1. **WebP directo** (recomendado, soporte universal en navegadores
+ modernos):
+
+ 
+
+2. **Con fallback `<picture>`** para navegadores antiguos. Como smu no
+ genera `<picture>`, escribelo en un post `.html`:
+
+ <picture>
+ <source srcset="/img/ejemplo-640.webp" type="image/webp">
+ <img src="/img/ejemplo-640.jpg" alt="...">
+ </picture>
+
+3. **Solo JPEG** (no usar WebP):
+
+ 
diff --git a/simpleblog b/simpleblog
@@ -0,0 +1,584 @@
+#!/bin/sh
+# simpleblog — generador estatico minimalista de blog
+# uso: ./simpleblog [build|clean]
+set -eu
+
+SRC="${SIMPLEBLOG_SRC:-$(cd "$(dirname "$0")" && pwd)}"
+OUT="${SIMPLEBLOG_OUT:-$SRC/public}"
+POSTS="$SRC/posts"
+PAGES="$SRC/pages"
+TPL="$SRC/templates"
+STATIC="$SRC/static"
+IMAGES="$SRC/images"
+CONF="$SRC/config"
+
+# --- utilidades ----------------------------------------------------------
+
+# leer valor del fichero de config (key: value)
+conf() {
+ awk -v k="$1" '
+ /^[[:space:]]*#/ {next}
+ {
+ i=index($0, ":")
+ if (!i) next
+ key=substr($0, 1, i-1); val=substr($0, i+1)
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
+ if (key==k) { print val; exit }
+ }
+ ' "$CONF"
+}
+
+# escape para insertar valores en plantillas via sed
+sed_escape() {
+ printf '%s' "$1" | sed 's/[&/\]/\\&/g; s/$/\\n/' | tr -d '\n' | sed 's/\\n$//'
+}
+
+# sustituye {{key}} en $1 por valor de $2 (escapado para sed)
+sub_var() {
+ sed -i "s/{{$1}}/$2/g" "$3"
+}
+
+# sustituye {{key}} en $1 por el contenido literal del fichero $2.
+# usamos awk porque sed se atraganta con & \ y newlines del contenido.
+sub_file() {
+ key="$1"; file="$2"; target="$3"
+ awk -v key="{{$key}}" -v file="$file" '
+ BEGIN {
+ while ((getline line < file) > 0) blob = blob line "\n"
+ close(file)
+ sub(/\n$/, "", blob)
+ }
+ {
+ while ((n = index($0, key)) > 0) {
+ printf "%s", substr($0, 1, n-1)
+ printf "%s", blob
+ $0 = substr($0, n + length(key))
+ }
+ print
+ }
+ ' "$target" > "$target.tmp" && mv "$target.tmp" "$target"
+}
+
+# aplica todas las variables de $2 (key<TAB>valor) a la plantilla $1 -> $3
+render() {
+ tpl="$1"; vars="$2"; out="$3"
+ cp "$tpl" "$out"
+ while IFS=' ' read -r k v; do
+ [ -z "$k" ] && continue
+ sub_var "$k" "$v" "$out"
+ done < "$vars"
+}
+
+# extraer frontmatter de un .md/.html. cuerpo -> $2, meta key<TAB>valor -> $3
+parse_post() {
+ awk -v body="$2" -v meta="$3" '
+ BEGIN { inhead=1 }
+ inhead && /^[[:space:]]*$/ { inhead=0; next }
+ inhead {
+ i=index($0, ":")
+ if (!i) next
+ k=substr($0, 1, i-1); v=substr($0, i+1)
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", k)
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
+ print k "\t" v > meta
+ next
+ }
+ { print > body }
+ ' "$1"
+}
+
+meta_get() {
+ awk -F'\t' -v k="$1" '$1==k {print $2; exit}' "$2"
+}
+
+xml_escape() {
+ printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' \
+ -e "s/'/\'/g" -e 's/"/\"/g'
+}
+
+# YYYY-MM-DD -> YYYY-MM-DDT00:00:00Z; el resto lo deja
+to_rfc3339() {
+ case "$1" in
+ ????-??-??) printf '%sT00:00:00Z' "$1" ;;
+ *) printf '%s' "$1" ;;
+ esac
+}
+
+# splittea "foo, bar,baz" -> una tag por linea, trim de espacios
+split_tags() {
+ printf '%s' "$1" | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
+ | grep -v '^$' || true
+}
+
+# true si $1 (origen) no existe o es mas nuevo que $2 (destino)
+needs_update() {
+ [ -f "$2" ] || return 0
+ [ "$1" -nt "$2" ] && return 0
+ return 1
+}
+
+# procesar images/: copiar original y generar variantes por cada ancho.
+# no hace upscale (el ">" de magick); cachea por mtime.
+process_images() {
+ [ -d "$IMAGES" ] || return 0
+
+ sizes=$(conf image_sizes)
+ quality=$(conf image_quality)
+ [ -z "$quality" ] && quality=85
+ extras=$(conf image_extra_formats)
+
+ if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then
+ echo "warn: ni magick ni convert disponibles, copiando images/ tal cual" >&2
+ mkdir -p "$OUT/img"
+ cp -r "$IMAGES"/. "$OUT/img"/
+ return 0
+ fi
+ if command -v magick >/dev/null 2>&1; then
+ IM="magick"
+ else
+ IM="convert"
+ fi
+
+ mkdir -p "$OUT/img"
+ gen=0; cached=0
+ for img in "$IMAGES"/*; do
+ [ -f "$img" ] || continue
+ name=${img##*/}
+ base=${name%.*}
+ ext=${name##*.}
+
+ # original (sin sufijo)
+ if needs_update "$img" "$OUT/img/$name"; then
+ cp "$img" "$OUT/img/$name"
+ gen=$((gen+1))
+ else
+ cached=$((cached+1))
+ fi
+
+ [ -z "$sizes" ] && continue
+ for w in $(printf '%s' "$sizes" | tr ',' ' '); do
+ [ -z "$w" ] && continue
+ # variante en formato original
+ dest="$OUT/img/$base-$w.$ext"
+ if needs_update "$img" "$dest"; then
+ "$IM" "$img" -resize "${w}x>" -quality "$quality" \
+ -strip "$dest" 2>/dev/null
+ gen=$((gen+1))
+ else
+ cached=$((cached+1))
+ fi
+ # variantes en formatos extra (webp, avif, ...)
+ for fmt in $(printf '%s' "$extras" | tr ',' ' '); do
+ [ -z "$fmt" ] && continue
+ destx="$OUT/img/$base-$w.$fmt"
+ if needs_update "$img" "$destx"; then
+ "$IM" "$img" -resize "${w}x>" -quality "$quality" \
+ -strip "$destx" 2>/dev/null
+ gen=$((gen+1))
+ else
+ cached=$((cached+1))
+ fi
+ done
+ done
+ done
+ echo "imagenes: $gen generadas, $cached en cache"
+}
+
+cmd_stats() {
+ echo "=== simpleblog stats ==="
+ echo
+ if [ -d "$IMAGES" ]; then
+ orig_bytes=$(find "$IMAGES" -type f -exec wc -c {} + | awk 'END{print $1+0}')
+ orig_count=$(find "$IMAGES" -type f | wc -l)
+ printf 'originales (images/): %s ficheros, %s\n' \
+ "$orig_count" "$(human_bytes "$orig_bytes")"
+ fi
+ if [ -d "$OUT" ]; then
+ out_bytes=$(find "$OUT" -type f -exec wc -c {} + | awk 'END{print $1+0}')
+ out_count=$(find "$OUT" -type f | wc -l)
+ printf 'salida (public/): %s ficheros, %s\n' \
+ "$out_count" "$(human_bytes "$out_bytes")"
+ if [ -d "$OUT/img" ]; then
+ img_bytes=$(find "$OUT/img" -type f -exec wc -c {} + | awk 'END{print $1+0}')
+ img_count=$(find "$OUT/img" -type f | wc -l)
+ printf ' variantes de imagen: %s ficheros, %s\n' \
+ "$img_count" "$(human_bytes "$img_bytes")"
+ fi
+ fi
+ echo
+ [ -d "$POSTS" ] && printf 'posts: %s\n' "$(find "$POSTS" -type f \( -name '*.md' -o -name '*.html' \) | wc -l)"
+ [ -d "$PAGES" ] && printf 'pages: %s\n' "$(find "$PAGES" -type f \( -name '*.md' -o -name '*.html' \) | wc -l)"
+ [ -d "$OUT/tags" ] && printf 'tags: %s\n' "$(find "$OUT/tags" -name '*.html' | wc -l)"
+}
+
+# bytes -> "12 KB" / "3,4 MB"
+human_bytes() {
+ awk -v b="$1" 'BEGIN{
+ if (b<1024) { printf "%d B", b; exit }
+ if (b<1024*1024) { printf "%.1f KB", b/1024; exit }
+ printf "%.1f MB", b/1024/1024
+ }'
+}
+
+# --- comandos ------------------------------------------------------------
+
+cmd_clean() {
+ rm -rf "$OUT"
+ mkdir -p "$OUT"
+}
+
+# escribe en $tmp/common.vars las variables que comparten todos los layouts
+# (excepto page_title/page_description, que dependen de cada pagina).
+write_common_vars() {
+ {
+ printf 'title\t%s\n' "$(sed_escape "$site_title")"
+ printf 'description\t%s\n' "$(sed_escape "$site_desc")"
+ printf 'author\t%s\n' "$(sed_escape "$site_author")"
+ printf 'baseurl\t%s\n' "$(sed_escape "$site_baseurl")"
+ printf 'siteurl\t%s\n' "$(sed_escape "$site_url")"
+ printf 'feed\t%s\n' "$(sed_escape "$site_feed")"
+ printf 'lang\t%s\n' "$(sed_escape "$site_lang")"
+ } > "$1"
+}
+
+# render_layout body_file out_file page_title page_description
+# concatena header.html + body + footer.html, sustituyendo variables comunes
+# (incluyendo {{menu}} desde $tmp/menu.html).
+render_layout() {
+ bodyf="$1"; outf="$2"; pt="$3"; pd="$4"
+ vars="$tmp/layout.vars"
+ cp "$tmp/common.vars" "$vars"
+ {
+ printf 'page_title\t%s\n' "$(sed_escape "$pt")"
+ printf 'page_description\t%s\n' "$(sed_escape "$pd")"
+ } >> "$vars"
+ render "$TPL/header.html" "$vars" "$tmp/_layout.header"
+ render "$TPL/footer.html" "$vars" "$tmp/_layout.footer"
+ # {{menu}} en el header se inserta como bloque (HTML, contiene &/<>)
+ sub_file menu "$tmp/menu.html" "$tmp/_layout.header"
+ cat "$tmp/_layout.header" "$bodyf" "$tmp/_layout.footer" > "$outf"
+}
+
+cmd_build() {
+ mkdir -p "$OUT"
+
+ site_title=$(conf title)
+ site_desc=$(conf description)
+ site_author=$(conf author)
+ site_baseurl=$(conf baseurl)
+ site_url=$(conf siteurl)
+ site_lang=$(conf lang)
+ site_feed=$(conf feed)
+ posts_per_page=$(conf posts_per_page)
+ [ -z "$posts_per_page" ] && posts_per_page=0
+ md_cmd=$(conf markdown_cmd)
+ [ -z "$md_cmd" ] && md_cmd=markdown
+ case "$md_cmd" in
+ ./*) md_cmd="$SRC/${md_cmd#./}" ;;
+ esac
+
+ tmp=$(mktemp -d)
+ trap 'rm -rf "$tmp"' EXIT
+
+ write_common_vars "$tmp/common.vars"
+ : > "$tmp/menu.html" # vacio por defecto (se sobrescribe abajo)
+
+ drafts=0
+ : > "$tmp/menu_items.txt" # order<TAB>title<TAB>slug
+ : > "$tmp/index_items.txt" # date<TAB>slug<TAB>url<TAB>title<TAB>description
+ : > "$tmp/tag_items.txt" # tag<TAB>date<TAB>slug<TAB>url<TAB>title<TAB>description
+
+ # === FASE 1: paginas estaticas (pages/) ===
+ # genera el HTML del cuerpo de cada pagina y recolecta items del menu.
+ # el render completo se hace despues (fase 2) cuando ya tenemos el menu.
+ if [ -d "$PAGES" ]; then
+ for src in "$PAGES"/*.md "$PAGES"/*.html; do
+ [ -e "$src" ] || continue
+ name=${src##*/}; ext=${name##*.}; slug=${name%.*}
+
+ body="$tmp/p.$slug.body.src"; meta="$tmp/p.$slug.meta"
+ : > "$body"; : > "$meta"
+ parse_post "$src" "$body" "$meta"
+
+ if [ "$(meta_get draft "$meta")" = "true" ]; then
+ drafts=$((drafts + 1)); continue
+ fi
+ custom_slug=$(meta_get slug "$meta")
+ [ -n "$custom_slug" ] && slug="$custom_slug"
+
+ title=$(meta_get title "$meta")
+ [ -z "$title" ] && title="$slug"
+ desc=$(meta_get description "$meta")
+
+ if [ "$ext" = "html" ]; then
+ cp "$body" "$tmp/p.$slug.body.html"
+ else
+ "$md_cmd" < "$body" > "$tmp/p.$slug.body.html"
+ fi
+
+ # marcador en disco con metadatos de la pagina para fase 2
+ printf '%s\t%s\t%s\n' "$slug" "$title" "$desc" >> "$tmp/pages_list.txt"
+
+ # acumular en menu si procede
+ if [ "$(meta_get menu "$meta")" = "true" ]; then
+ order=$(meta_get menu_order "$meta")
+ [ -z "$order" ] && order=100
+ printf '%s\t%s\t%s\n' "$order" "$title" "$slug" \
+ >> "$tmp/menu_items.txt"
+ fi
+ done
+ fi
+
+ # construir HTML del menu a partir de menu_items.txt (orden numerico)
+ if [ -s "$tmp/menu_items.txt" ]; then
+ sort -n "$tmp/menu_items.txt" | while IFS=' ' read -r o t s; do
+ printf '<a href="%s%s.html">%s</a>\n' \
+ "$site_baseurl" "$s" "$t"
+ done > "$tmp/menu.html"
+ fi
+
+ # === FASE 2: render final de paginas estaticas (ya con el menu) ===
+ if [ -s "$tmp/pages_list.txt" ]; then
+ while IFS=' ' read -r slug title desc; do
+ pvars="$tmp/p.$slug.vars"
+ { printf 'title\t%s\n' "$(sed_escape "$title")"; } > "$pvars"
+ cp "$TPL/page.html" "$tmp/p.$slug.body"
+ while IFS=' ' read -r k v; do
+ sub_var "$k" "$v" "$tmp/p.$slug.body"
+ done < "$pvars"
+ sub_file content "$tmp/p.$slug.body.html" "$tmp/p.$slug.body"
+ render_layout "$tmp/p.$slug.body" "$OUT/$slug.html" \
+ "$title — $site_title" "${desc:-$site_desc}"
+ done < "$tmp/pages_list.txt"
+ fi
+
+ # === FASE 3: posts ===
+ for src in "$POSTS"/*.md "$POSTS"/*.html; do
+ [ -e "$src" ] || continue
+ name=${src##*/}; ext=${name##*.}; slug=${name%.*}
+
+ body="$tmp/$slug.body.src"; meta="$tmp/$slug.meta"
+ : > "$body"; : > "$meta"
+ parse_post "$src" "$body" "$meta"
+
+ if [ "$(meta_get draft "$meta")" = "true" ]; then
+ drafts=$((drafts + 1)); continue
+ fi
+ custom_slug=$(meta_get slug "$meta")
+ [ -n "$custom_slug" ] && slug="$custom_slug"
+
+ title=$(meta_get title "$meta")
+ date=$(meta_get date "$meta")
+ desc=$(meta_get description "$meta")
+ tags_raw=$(meta_get tags "$meta")
+ [ -z "$title" ] && title="$slug"
+
+ if [ "$ext" = "html" ]; then
+ cp "$body" "$tmp/$slug.body.html"
+ else
+ "$md_cmd" < "$body" > "$tmp/$slug.body.html"
+ fi
+
+ # HTML de tags para mostrar en el propio post + acumular en tag_items
+ tags_html=""
+ if [ -n "$tags_raw" ]; then
+ tags_html='<span class="post-tags">'
+ for tg in $(split_tags "$tags_raw"); do
+ tags_html="$tags_html<a href=\"${site_baseurl}tags/${tg}.html\">$tg</a> "
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
+ "$tg" "$date" "$slug" "$slug.html" "$title" "${desc:-}" \
+ >> "$tmp/tag_items.txt"
+ done
+ tags_html="${tags_html}</span>"
+ fi
+
+ # render post body
+ pvars="$tmp/$slug.pvars"
+ {
+ printf 'title\t%s\n' "$(sed_escape "$title")"
+ printf 'date\t%s\n' "$(sed_escape "$date")"
+ printf 'tags\t%s\n' "$(sed_escape "$tags_html")"
+ } > "$pvars"
+ cp "$TPL/post.html" "$tmp/$slug.body"
+ while IFS=' ' read -r k v; do
+ sub_var "$k" "$v" "$tmp/$slug.body"
+ done < "$pvars"
+ sub_file content "$tmp/$slug.body.html" "$tmp/$slug.body"
+
+ render_layout "$tmp/$slug.body" "$OUT/$slug.html" \
+ "$title — $site_title" "${desc:-$site_desc}"
+
+ printf '%s\t%s\t%s\t%s\t%s\n' "$date" "$slug" "$slug.html" "$title" "${desc:-}" \
+ >> "$tmp/index_items.txt"
+ done
+
+ # === FASE 4: indice (con paginacion) ===
+ sort -r "$tmp/index_items.txt" > "$tmp/index_items.sorted"
+
+ total=$(wc -l < "$tmp/index_items.sorted" | tr -d ' ')
+ if [ "$posts_per_page" -le 0 ] || [ "$total" -le "$posts_per_page" ]; then
+ # una sola pagina
+ render_index_page "$tmp/index_items.sorted" "Entradas" "" "" "$OUT/index.html"
+ else
+ mkdir -p "$OUT/page"
+ page=1; start=1
+ while [ "$start" -le "$total" ]; do
+ end=$((start + posts_per_page - 1))
+ [ "$end" -gt "$total" ] && end="$total"
+ chunk="$tmp/index_chunk_$page"
+ sed -n "${start},${end}p" "$tmp/index_items.sorted" > "$chunk"
+
+ # enlaces prev/next
+ prev=""
+ next=""
+ if [ "$page" -gt 1 ]; then
+ if [ "$page" -eq 2 ]; then
+ prev="<a href=\"${site_baseurl}\">« mas recientes</a>"
+ else
+ prev="<a href=\"${site_baseurl}page/$((page-1)).html\">« mas recientes</a>"
+ fi
+ fi
+ if [ "$end" -lt "$total" ]; then
+ next="<a href=\"${site_baseurl}page/$((page+1)).html\">mas antiguas »</a>"
+ fi
+
+ if [ "$page" -eq 1 ]; then
+ outf="$OUT/index.html"
+ else
+ outf="$OUT/page/$page.html"
+ fi
+ render_index_page "$chunk" "Entradas" "$prev" "$next" "$outf"
+
+ start=$((end + 1)); page=$((page + 1))
+ done
+ fi
+
+ # === FASE 5: pagina por tag ===
+ if [ -s "$tmp/tag_items.txt" ]; then
+ mkdir -p "$OUT/tags"
+ cut -f1 "$tmp/tag_items.txt" | sort -u > "$tmp/tags_unique.txt"
+ while IFS= read -r tag; do
+ awk -F'\t' -v t="$tag" 'BEGIN{OFS=FS} $1==t {print $2,$3,$4,$5,$6}' \
+ "$tmp/tag_items.txt" | sort -r > "$tmp/tag_$tag.sorted"
+ render_index_page "$tmp/tag_$tag.sorted" "Tag: $tag" "" "" \
+ "$OUT/tags/$tag.html"
+ done < "$tmp/tags_unique.txt"
+ fi
+
+ # === FASE 6: indice global de tags ===
+ if [ -s "$tmp/tag_items.txt" ]; then
+ : > "$tmp/tags_index.html"
+ cut -f1 "$tmp/tag_items.txt" | sort | uniq -c | sort -rn | \
+ while read -r count tag; do
+ printf ' <li><a href="%stags/%s.html">%s</a> <small>(%s)</small></li>\n' \
+ "$site_baseurl" "$tag" "$tag" "$count" >> "$tmp/tags_index.html"
+ done
+ cp "$TPL/tags.html" "$tmp/tags.body"
+ sub_file items "$tmp/tags_index.html" "$tmp/tags.body"
+ render_layout "$tmp/tags.body" "$OUT/tags.html" \
+ "Tags — $site_title" "Indice de tags"
+ fi
+
+ # === FASE 7: feed atom ===
+ if [ -n "$site_feed" ]; then
+ feed_updated=""
+ read -r first < "$tmp/index_items.sorted" || true
+ if [ -n "$first" ]; then
+ d=$(printf '%s' "$first" | cut -f1)
+ feed_updated=$(to_rfc3339 "$d")
+ fi
+ [ -z "$feed_updated" ] && feed_updated=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+
+ fvars="$tmp/feed.vars"
+ {
+ printf 'title\t%s\n' "$(sed_escape "$(xml_escape "$site_title")")"
+ printf 'description\t%s\n' "$(sed_escape "$(xml_escape "$site_desc")")"
+ printf 'author\t%s\n' "$(sed_escape "$(xml_escape "$site_author")")"
+ printf 'baseurl\t%s\n' "$(sed_escape "$site_baseurl")"
+ printf 'siteurl\t%s\n' "$(sed_escape "$site_url")"
+ printf 'feed\t%s\n' "$(sed_escape "$site_feed")"
+ printf 'feed_updated\t%s\n' "$(sed_escape "$feed_updated")"
+ } > "$fvars"
+ render "$TPL/atom-header.xml" "$fvars" "$tmp/feed.header"
+
+ : > "$tmp/feed.items"
+ while IFS=' ' read -r d s u t desc; do
+ [ -z "$u" ] && continue
+ updated=$(to_rfc3339 "$d")
+ absurl="$site_url$site_baseurl$u"
+
+ evars="$tmp/e.vars"
+ {
+ printf 'title\t%s\n' "$(sed_escape "$(xml_escape "$t")")"
+ printf 'absurl\t%s\n' "$(sed_escape "$(xml_escape "$absurl")")"
+ printf 'updated\t%s\n' "$(sed_escape "$updated")"
+ printf 'description\t%s\n' "$(sed_escape "$(xml_escape "$desc")")"
+ } > "$evars"
+ cp "$TPL/atom-item.xml" "$tmp/e.out"
+ while IFS=' ' read -r k v; do
+ sub_var "$k" "$v" "$tmp/e.out"
+ done < "$evars"
+ if [ -f "$tmp/$s.body.html" ]; then
+ sub_file content "$tmp/$s.body.html" "$tmp/e.out"
+ else
+ sub_var content "" "$tmp/e.out"
+ fi
+ cat "$tmp/e.out" >> "$tmp/feed.items"
+ done < "$tmp/index_items.sorted"
+
+ render "$TPL/atom-footer.xml" "$fvars" "$tmp/feed.footer"
+ cat "$tmp/feed.header" "$tmp/feed.items" "$tmp/feed.footer" > "$OUT/$site_feed"
+ fi
+
+ # === FASE 8: imagenes (originales en images/ -> public/img/*) ===
+ process_images
+
+ # === FASE 9: static tal cual ===
+ if [ -d "$STATIC" ]; then
+ cp -r "$STATIC"/. "$OUT"/
+ fi
+
+ if [ "$drafts" -gt 0 ]; then
+ echo "build ok -> $OUT (saltados $drafts borradores)"
+ else
+ echo "build ok -> $OUT"
+ fi
+}
+
+# render_index_page items_file list_title prev_link next_link out_file
+# items_file: lineas date<TAB>slug<TAB>url<TAB>title<TAB>description
+render_index_page() {
+ items="$1"; ltitle="$2"; prev="$3"; next="$4"; outf="$5"
+
+ : > "$tmp/_items_html"
+ while IFS=' ' read -r d s u t desc; do
+ [ -z "$u" ] && continue
+ ivars="$tmp/_i.vars"
+ {
+ printf 'date\t%s\n' "$(sed_escape "$d")"
+ printf 'url\t%s\n' "$(sed_escape "$site_baseurl$u")"
+ printf 'title\t%s\n' "$(sed_escape "$t")"
+ printf 'description\t%s\n' "$(sed_escape "$desc")"
+ } > "$ivars"
+ render "$TPL/index-item.html" "$ivars" "$tmp/_i.out"
+ cat "$tmp/_i.out" >> "$tmp/_items_html"
+ done < "$items"
+
+ cp "$TPL/index.html" "$tmp/_index.body"
+ sub_var list_title "$(sed_escape "$ltitle")" "$tmp/_index.body"
+ pag="$prev $next"
+ sub_var pagination "$(sed_escape "$pag")" "$tmp/_index.body"
+ sub_file items "$tmp/_items_html" "$tmp/_index.body"
+
+ render_layout "$tmp/_index.body" "$outf" "$ltitle — $site_title" "$site_desc"
+}
+
+# --- entrada -------------------------------------------------------------
+
+case "${1:-build}" in
+ build) cmd_build ;;
+ clean) cmd_clean ;;
+ stats) cmd_stats ;;
+ *) echo "uso: $0 [build|clean|stats]" >&2; exit 1 ;;
+esac
diff --git a/static/favicon.svg b/static/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+ <rect width="64" height="64" rx="12" fill="#0366d6"/>
+ <text x="32" y="44" text-anchor="middle" font-family="monospace" font-size="40" font-weight="bold" fill="#fff">s</text>
+</svg>
diff --git a/static/logo.svg b/static/logo.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32">
+ <rect width="64" height="64" rx="12" fill="#0366d6"/>
+ <text x="32" y="44" text-anchor="middle" font-family="monospace" font-size="40" font-weight="bold" fill="#fff">s</text>
+</svg>
diff --git a/static/style.css b/static/style.css
@@ -0,0 +1,44 @@
+/* simpleblog — css minimal */
+* { box-sizing: border-box; }
+body {
+ font-family: system-ui, sans-serif;
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 0 1rem;
+ line-height: 1.5;
+ color: #222;
+ background: #fafafa;
+}
+a { color: #0366d6; }
+a:hover { text-decoration: underline; }
+
+.site-header { border-bottom: 1px solid #ddd; padding-bottom: 1rem; margin-bottom: 2rem; }
+.site-brand { display: inline-flex; align-items: center; gap: 0.5rem; text-decoration: none; color: #222; }
+.site-logo { width: 1.75rem; height: 1.75rem; }
+.site-title { font-size: 1.5rem; font-weight: bold; }
+.site-description { color: #666; margin: 0.25rem 0 0; }
+.site-nav { margin-top: 0.75rem; }
+.site-nav a { margin-right: 0.75rem; }
+
+.pagination { margin-top: 2rem; }
+.pagination a { margin-right: 1rem; }
+
+.post-tags { margin-left: 0.5rem; }
+.post-tags a { background: #eef; padding: 0.05rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
+
+.tag-cloud ul { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
+.tag-cloud li a { background: #eef; padding: 0.2rem 0.6rem; border-radius: 3px; }
+
+.site-footer { border-top: 1px solid #ddd; margin-top: 3rem; padding-top: 1rem; color: #666; font-size: 0.9rem; }
+
+.post-list ul { list-style: none; padding: 0; }
+.post-item { margin-bottom: 1.25rem; }
+.post-item time { display: inline-block; color: #888; font-family: monospace; margin-right: 0.5rem; }
+.post-summary { color: #555; margin: 0.25rem 0 0; }
+
+.post-header h1 { margin-bottom: 0.25rem; }
+.post-meta { color: #888; font-family: monospace; margin-top: 0; }
+.post-content { margin-top: 2rem; }
+.post-content pre { background: #f0f0f0; padding: 0.75rem; overflow-x: auto; }
+.post-content code { background: #f0f0f0; padding: 0.1rem 0.3rem; }
+.post-content pre code { padding: 0; background: none; }
diff --git a/templates/atom-footer.xml b/templates/atom-footer.xml
@@ -0,0 +1 @@
+</feed>
diff --git a/templates/atom-header.xml b/templates/atom-header.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+ <title>{{title}}</title>
+ <subtitle>{{description}}</subtitle>
+ <link href="{{siteurl}}{{baseurl}}"/>
+ <link href="{{siteurl}}{{baseurl}}{{feed}}" rel="self" type="application/atom+xml"/>
+ <id>{{siteurl}}{{baseurl}}</id>
+ <updated>{{feed_updated}}</updated>
+ <author><name>{{author}}</name></author>
diff --git a/templates/atom-item.xml b/templates/atom-item.xml
@@ -0,0 +1,8 @@
+ <entry>
+ <title>{{title}}</title>
+ <link href="{{absurl}}"/>
+ <id>{{absurl}}</id>
+ <updated>{{updated}}</updated>
+ <summary>{{description}}</summary>
+ <content type="html"><![CDATA[{{content}}]]></content>
+ </entry>
diff --git a/templates/footer.html b/templates/footer.html
@@ -0,0 +1,6 @@
+</main>
+<footer class="site-footer">
+ <p>© {{author}}</p>
+</footer>
+</body>
+</html>
diff --git a/templates/header.html b/templates/header.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="{{lang}}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{page_title}}</title>
+<meta name="description" content="{{page_description}}">
+<link rel="icon" type="image/svg+xml" href="{{baseurl}}favicon.svg">
+<link rel="stylesheet" href="{{baseurl}}style.css">
+<link rel="alternate" type="application/atom+xml" href="{{baseurl}}{{feed}}" title="{{title}}">
+</head>
+<body>
+<header class="site-header">
+ <a href="{{baseurl}}" class="site-brand">
+ <img src="{{baseurl}}logo.svg" alt="" class="site-logo">
+ <span class="site-title">{{title}}</span>
+ </a>
+ <p class="site-description">{{description}}</p>
+ <nav class="site-nav">{{menu}}</nav>
+</header>
+<main>
diff --git a/templates/index-item.html b/templates/index-item.html
@@ -0,0 +1,5 @@
+ <li class="post-item">
+ <time datetime="{{date}}">{{date}}</time>
+ <a href="{{url}}">{{title}}</a>
+ <p class="post-summary">{{description}}</p>
+ </li>
diff --git a/templates/index.html b/templates/index.html
@@ -0,0 +1,7 @@
+<section class="post-list">
+ <h2>{{list_title}}</h2>
+ <ul>
+{{items}}
+ </ul>
+ <nav class="pagination">{{pagination}}</nav>
+</section>
diff --git a/templates/page.html b/templates/page.html
@@ -0,0 +1,8 @@
+<article class="page">
+ <header class="page-header">
+ <h1>{{title}}</h1>
+ </header>
+ <div class="page-content">
+{{content}}
+ </div>
+</article>
diff --git a/templates/post.html b/templates/post.html
@@ -0,0 +1,12 @@
+<article class="post">
+ <header class="post-header">
+ <h1>{{title}}</h1>
+ <p class="post-meta">
+ <time datetime="{{date}}">{{date}}</time>
+ {{tags}}
+ </p>
+ </header>
+ <div class="post-content">
+{{content}}
+ </div>
+</article>
diff --git a/templates/tags.html b/templates/tags.html
@@ -0,0 +1,6 @@
+<section class="tag-cloud">
+ <h2>Tags</h2>
+ <ul>
+{{items}}
+ </ul>
+</section>