simpleblog

Generador estatico minimalista de blog
git clone https://git.fedi.ovh/simpleblog
Log | Files | Refs

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:
A.gitignore | 10++++++++++
AMakefile | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aconfig | 29+++++++++++++++++++++++++++++
Aimages/ejemplo.jpg | 0
Aobjetivo.txt | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apages/about.md | 12++++++++++++
Apages/contacto.md | 8++++++++
Aposts/2026-05-18-hola-mundo.md | 25+++++++++++++++++++++++++
Aposts/2026-05-18-pagina-en-html.html | 24++++++++++++++++++++++++
Aposts/2026-05-19-borrador.md | 6++++++
Aposts/2026-05-20-con-slug.md | 11+++++++++++
Aposts/2026-05-21-imagenes.md | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Asimpleblog | 584+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/favicon.svg | 4++++
Astatic/logo.svg | 4++++
Astatic/style.css | 44++++++++++++++++++++++++++++++++++++++++++++
Atemplates/atom-footer.xml | 1+
Atemplates/atom-header.xml | 9+++++++++
Atemplates/atom-item.xml | 8++++++++
Atemplates/footer.html | 6++++++
Atemplates/header.html | 21+++++++++++++++++++++
Atemplates/index-item.html | 5+++++
Atemplates/index.html | 7+++++++
Atemplates/page.html | 8++++++++
Atemplates/post.html | 12++++++++++++
Atemplates/tags.html | 6++++++
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 `![alt](/img/foo-640.jpg)` 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: + +![Imagen de prueba a 640px](/img/ejemplo-640.jpg) + +La misma a 320 px: + +![Imagen de prueba a 320px](/img/ejemplo-320.jpg) + +Y a 1280: + +![Imagen de prueba a 1280px](/img/ejemplo-1280.jpg) + +Original (sin sufijo, tal cual): + +![Imagen original 2400x1600](/img/ejemplo.jpg) + +## 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): + + ![alt](/img/ejemplo-640.webp) + +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): + + ![alt](/img/ejemplo-640.jpg) 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/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' \ + -e "s/'/\&#39;/g" -e 's/"/\&quot;/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}\">&laquo; mas recientes</a>" + else + prev="<a href=\"${site_baseurl}page/$((page-1)).html\">&laquo; mas recientes</a>" + fi + fi + if [ "$end" -lt "$total" ]; then + next="<a href=\"${site_baseurl}page/$((page+1)).html\">mas antiguas &raquo;</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>&copy; {{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>