simpleblog

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

simpleblog (20006B)


      1 #!/bin/sh
      2 # simpleblog — generador estatico minimalista de blog
      3 # uso: simpleblog [init|build|clean|stats|serve]
      4 #
      5 # trabaja sobre el directorio actual (el sitio). las plantillas y el skel
      6 # por defecto viven en $SIMPLEBLOG_DEFAULTS (default /usr/share/simpleblog).
      7 # si el sitio tiene su propia ./templates/<name>, esa gana sobre el default.
      8 set -eu
      9 
     10 SITE_DIR="${SIMPLEBLOG_SITE:-$(pwd)}"
     11 DEFAULTS="${SIMPLEBLOG_DEFAULTS:-/usr/share/simpleblog}"
     12 
     13 OUT="${SIMPLEBLOG_OUT:-$SITE_DIR/public}"
     14 POSTS="$SITE_DIR/posts"
     15 PAGES="$SITE_DIR/pages"
     16 STATIC="$SITE_DIR/static"
     17 IMAGES="$SITE_DIR/images"
     18 CONF="$SITE_DIR/config"
     19 
     20 # devuelve la ruta a una plantilla con override: si existe en el sitio, esa;
     21 # si no, la del directorio de defaults.
     22 tpl() {
     23 	if [ -f "$SITE_DIR/templates/$1" ]; then
     24 		printf '%s\n' "$SITE_DIR/templates/$1"
     25 	else
     26 		printf '%s\n' "$DEFAULTS/templates/$1"
     27 	fi
     28 }
     29 
     30 # --- utilidades ----------------------------------------------------------
     31 
     32 # leer valor del fichero de config (key: value)
     33 conf() {
     34 	awk -v k="$1" '
     35 		/^[[:space:]]*#/ {next}
     36 		{
     37 			i=index($0, ":")
     38 			if (!i) next
     39 			key=substr($0, 1, i-1); val=substr($0, i+1)
     40 			gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
     41 			gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
     42 			if (key==k) { print val; exit }
     43 		}
     44 	' "$CONF"
     45 }
     46 
     47 # escape para insertar valores en plantillas via sed
     48 sed_escape() {
     49 	printf '%s' "$1" | sed 's/[&/\]/\\&/g; s/$/\\n/' | tr -d '\n' | sed 's/\\n$//'
     50 }
     51 
     52 # sustituye {{key}} en $1 por valor de $2 (escapado para sed)
     53 sub_var() {
     54 	sed -i "s/{{$1}}/$2/g" "$3"
     55 }
     56 
     57 # sustituye {{key}} en $1 por el contenido literal del fichero $2.
     58 # usamos awk porque sed se atraganta con & \ y newlines del contenido.
     59 sub_file() {
     60 	key="$1"; file="$2"; target="$3"
     61 	awk -v key="{{$key}}" -v file="$file" '
     62 		BEGIN {
     63 			while ((getline line < file) > 0) blob = blob line "\n"
     64 			close(file)
     65 			sub(/\n$/, "", blob)
     66 		}
     67 		{
     68 			while ((n = index($0, key)) > 0) {
     69 				printf "%s", substr($0, 1, n-1)
     70 				printf "%s", blob
     71 				$0 = substr($0, n + length(key))
     72 			}
     73 			print
     74 		}
     75 	' "$target" > "$target.tmp" && mv "$target.tmp" "$target"
     76 }
     77 
     78 # aplica todas las variables de $2 (key<TAB>valor) a la plantilla $1 -> $3
     79 render() {
     80 	template="$1"; vars="$2"; out="$3"
     81 	cp "$template" "$out"
     82 	while IFS='	' read -r k v; do
     83 		[ -z "$k" ] && continue
     84 		sub_var "$k" "$v" "$out"
     85 	done < "$vars"
     86 }
     87 
     88 # extraer frontmatter de un .md/.html. cuerpo -> $2, meta key<TAB>valor -> $3
     89 parse_post() {
     90 	awk -v body="$2" -v meta="$3" '
     91 		BEGIN { inhead=1 }
     92 		inhead && /^[[:space:]]*$/ { inhead=0; next }
     93 		inhead {
     94 			i=index($0, ":")
     95 			if (!i) next
     96 			k=substr($0, 1, i-1); v=substr($0, i+1)
     97 			gsub(/^[[:space:]]+|[[:space:]]+$/, "", k)
     98 			gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
     99 			print k "\t" v > meta
    100 			next
    101 		}
    102 		{ print > body }
    103 	' "$1"
    104 }
    105 
    106 meta_get() {
    107 	awk -F'\t' -v k="$1" '$1==k {print $2; exit}' "$2"
    108 }
    109 
    110 xml_escape() {
    111 	printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' \
    112 		-e "s/'/\&#39;/g" -e 's/"/\&quot;/g'
    113 }
    114 
    115 # YYYY-MM-DD -> YYYY-MM-DDT00:00:00Z; el resto lo deja
    116 to_rfc3339() {
    117 	case "$1" in
    118 		????-??-??) printf '%sT00:00:00Z' "$1" ;;
    119 		*) printf '%s' "$1" ;;
    120 	esac
    121 }
    122 
    123 # splittea "foo, bar,baz" -> una tag por linea, trim de espacios
    124 split_tags() {
    125 	printf '%s' "$1" | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
    126 		| grep -v '^$' || true
    127 }
    128 
    129 # true si $1 (origen) no existe o es mas nuevo que $2 (destino)
    130 needs_update() {
    131 	[ -f "$2" ] || return 0
    132 	[ "$1" -nt "$2" ] && return 0
    133 	return 1
    134 }
    135 
    136 # procesar images/: copiar original y generar variantes por cada ancho.
    137 # no hace upscale (el ">" de magick); cachea por mtime.
    138 process_images() {
    139 	[ -d "$IMAGES" ] || return 0
    140 
    141 	sizes=$(conf image_sizes)
    142 	quality=$(conf image_quality)
    143 	[ -z "$quality" ] && quality=85
    144 	extras=$(conf image_extra_formats)
    145 
    146 	if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then
    147 		echo "warn: ni magick ni convert disponibles, copiando images/ tal cual" >&2
    148 		mkdir -p "$OUT/img"
    149 		cp -r "$IMAGES"/. "$OUT/img"/
    150 		return 0
    151 	fi
    152 	if command -v magick >/dev/null 2>&1; then
    153 		IM="magick"
    154 	else
    155 		IM="convert"
    156 	fi
    157 
    158 	mkdir -p "$OUT/img"
    159 	gen=0; cached=0
    160 	for img in "$IMAGES"/*; do
    161 		[ -f "$img" ] || continue
    162 		name=${img##*/}
    163 		base=${name%.*}
    164 		ext=${name##*.}
    165 
    166 		# original (sin sufijo)
    167 		if needs_update "$img" "$OUT/img/$name"; then
    168 			cp "$img" "$OUT/img/$name"
    169 			gen=$((gen+1))
    170 		else
    171 			cached=$((cached+1))
    172 		fi
    173 
    174 		[ -z "$sizes" ] && continue
    175 		for w in $(printf '%s' "$sizes" | tr ',' ' '); do
    176 			[ -z "$w" ] && continue
    177 			# variante en formato original
    178 			dest="$OUT/img/$base-$w.$ext"
    179 			if needs_update "$img" "$dest"; then
    180 				"$IM" "$img" -resize "${w}x>" -quality "$quality" \
    181 					-strip "$dest" 2>/dev/null
    182 				gen=$((gen+1))
    183 			else
    184 				cached=$((cached+1))
    185 			fi
    186 			# variantes en formatos extra (webp, avif, ...)
    187 			for fmt in $(printf '%s' "$extras" | tr ',' ' '); do
    188 				[ -z "$fmt" ] && continue
    189 				destx="$OUT/img/$base-$w.$fmt"
    190 				if needs_update "$img" "$destx"; then
    191 					"$IM" "$img" -resize "${w}x>" -quality "$quality" \
    192 						-strip "$destx" 2>/dev/null
    193 					gen=$((gen+1))
    194 				else
    195 					cached=$((cached+1))
    196 				fi
    197 			done
    198 		done
    199 	done
    200 	echo "imagenes: $gen generadas, $cached en cache"
    201 }
    202 
    203 cmd_stats() {
    204 	echo "=== simpleblog stats ==="
    205 	echo
    206 	if [ -d "$IMAGES" ]; then
    207 		orig_bytes=$(find "$IMAGES" -type f -exec wc -c {} + | awk 'END{print $1+0}')
    208 		orig_count=$(find "$IMAGES" -type f | wc -l)
    209 		printf 'originales (images/):   %s ficheros, %s\n' \
    210 			"$orig_count" "$(human_bytes "$orig_bytes")"
    211 	fi
    212 	if [ -d "$OUT" ]; then
    213 		out_bytes=$(find "$OUT" -type f -exec wc -c {} + | awk 'END{print $1+0}')
    214 		out_count=$(find "$OUT" -type f | wc -l)
    215 		printf 'salida (public/):       %s ficheros, %s\n' \
    216 			"$out_count" "$(human_bytes "$out_bytes")"
    217 		if [ -d "$OUT/img" ]; then
    218 			img_bytes=$(find "$OUT/img" -type f -exec wc -c {} + | awk 'END{print $1+0}')
    219 			img_count=$(find "$OUT/img" -type f | wc -l)
    220 			printf '  variantes de imagen:  %s ficheros, %s\n' \
    221 				"$img_count" "$(human_bytes "$img_bytes")"
    222 		fi
    223 	fi
    224 	echo
    225 	[ -d "$POSTS" ] && printf 'posts:   %s\n' "$(find "$POSTS" -type f \( -name '*.md' -o -name '*.html' \) | wc -l)"
    226 	[ -d "$PAGES" ] && printf 'pages:   %s\n' "$(find "$PAGES" -type f \( -name '*.md' -o -name '*.html' \) | wc -l)"
    227 	[ -d "$OUT/tags" ] && printf 'tags:    %s\n' "$(find "$OUT/tags" -name '*.html' | wc -l)"
    228 }
    229 
    230 # bytes -> "12 KB" / "3,4 MB"
    231 human_bytes() {
    232 	awk -v b="$1" 'BEGIN{
    233 		if (b<1024) { printf "%d B", b; exit }
    234 		if (b<1024*1024) { printf "%.1f KB", b/1024; exit }
    235 		printf "%.1f MB", b/1024/1024
    236 	}'
    237 }
    238 
    239 # --- comandos ------------------------------------------------------------
    240 
    241 cmd_clean() {
    242 	rm -rf "$OUT"
    243 	mkdir -p "$OUT"
    244 }
    245 
    246 # init: copia $DEFAULTS/skel/ al directorio actual.
    247 cmd_init() {
    248 	if [ ! -d "$DEFAULTS/skel" ]; then
    249 		echo "no encuentro el skel en $DEFAULTS/skel" >&2
    250 		echo "define SIMPLEBLOG_DEFAULTS o instala simpleblog" >&2
    251 		exit 1
    252 	fi
    253 	# avisamos si el directorio no esta vacio (ignoramos .git)
    254 	if [ -n "$(ls -A "$SITE_DIR" 2>/dev/null | grep -v '^\.git$' || true)" ]; then
    255 		echo "el directorio $SITE_DIR no esta vacio. usa otro o limpia primero." >&2
    256 		exit 1
    257 	fi
    258 	cp -r "$DEFAULTS/skel"/. "$SITE_DIR"/
    259 	echo "sitio inicializado en $SITE_DIR"
    260 	echo "siguiente paso: edita config y posts/, luego 'simpleblog build'"
    261 }
    262 
    263 cmd_serve() {
    264 	port="${SIMPLEBLOG_PORT:-8080}"
    265 	[ -d "$OUT" ] || cmd_build
    266 	echo "serving $OUT/ on http://localhost:$port"
    267 	if command -v darkhttpd >/dev/null 2>&1; then
    268 		exec darkhttpd "$OUT" --port "$port"
    269 	elif command -v httpd >/dev/null 2>&1; then
    270 		exec httpd -f -p "$port" -h "$OUT"
    271 	elif command -v busybox-extras >/dev/null 2>&1; then
    272 		exec busybox-extras httpd -f -p "$port" -h "$OUT"
    273 	elif command -v busybox >/dev/null 2>&1 && busybox --list 2>/dev/null | grep -qx httpd; then
    274 		exec busybox httpd -f -p "$port" -h "$OUT"
    275 	elif command -v python3 >/dev/null 2>&1; then
    276 		cd "$OUT" && exec python3 -m http.server "$port"
    277 	else
    278 		echo "no httpd encontrado. instala darkhttpd o busybox-extras." >&2
    279 		exit 1
    280 	fi
    281 }
    282 
    283 # escribe en $tmp/common.vars las variables que comparten todos los layouts
    284 # (excepto page_title/page_description, que dependen de cada pagina).
    285 write_common_vars() {
    286 	{
    287 		printf 'title\t%s\n'       "$(sed_escape "$site_title")"
    288 		printf 'description\t%s\n' "$(sed_escape "$site_desc")"
    289 		printf 'author\t%s\n'      "$(sed_escape "$site_author")"
    290 		printf 'baseurl\t%s\n'     "$(sed_escape "$site_baseurl")"
    291 		printf 'siteurl\t%s\n'     "$(sed_escape "$site_url")"
    292 		printf 'feed\t%s\n'        "$(sed_escape "$site_feed")"
    293 		printf 'lang\t%s\n'        "$(sed_escape "$site_lang")"
    294 	} > "$1"
    295 }
    296 
    297 # render_layout body_file out_file page_title page_description
    298 # concatena header.html + body + footer.html, sustituyendo variables comunes
    299 # (incluyendo {{menu}} desde $tmp/menu.html).
    300 render_layout() {
    301 	bodyf="$1"; outf="$2"; pt="$3"; pd="$4"
    302 	vars="$tmp/layout.vars"
    303 	cp "$tmp/common.vars" "$vars"
    304 	{
    305 		printf 'page_title\t%s\n'       "$(sed_escape "$pt")"
    306 		printf 'page_description\t%s\n' "$(sed_escape "$pd")"
    307 	} >> "$vars"
    308 	render "$(tpl header.html)" "$vars" "$tmp/_layout.header"
    309 	render "$(tpl footer.html)" "$vars" "$tmp/_layout.footer"
    310 	# {{menu}} en el header se inserta como bloque (HTML, contiene &/<>)
    311 	sub_file menu "$tmp/menu.html" "$tmp/_layout.header"
    312 	cat "$tmp/_layout.header" "$bodyf" "$tmp/_layout.footer" > "$outf"
    313 }
    314 
    315 cmd_build() {
    316 	mkdir -p "$OUT"
    317 
    318 	site_title=$(conf title)
    319 	site_desc=$(conf description)
    320 	site_author=$(conf author)
    321 	site_baseurl=$(conf baseurl)
    322 	site_url=$(conf siteurl)
    323 	site_lang=$(conf lang)
    324 	site_feed=$(conf feed)
    325 	posts_per_page=$(conf posts_per_page)
    326 	[ -z "$posts_per_page" ] && posts_per_page=0
    327 	md_cmd=$(conf markdown_cmd)
    328 	[ -z "$md_cmd" ] && md_cmd=smu
    329 	# rutas relativas (./foo) se resuelven respecto al sitio
    330 	case "$md_cmd" in
    331 		./*) md_cmd="$SITE_DIR/${md_cmd#./}" ;;
    332 	esac
    333 
    334 	tmp=$(mktemp -d)
    335 	trap 'rm -rf "$tmp"' EXIT
    336 
    337 	write_common_vars "$tmp/common.vars"
    338 	: > "$tmp/menu.html"  # vacio por defecto (se sobrescribe abajo)
    339 
    340 	drafts=0
    341 	: > "$tmp/menu_items.txt"   # order<TAB>title<TAB>slug
    342 	: > "$tmp/index_items.txt"  # date<TAB>slug<TAB>url<TAB>title<TAB>description
    343 	: > "$tmp/tag_items.txt"    # tag<TAB>date<TAB>slug<TAB>url<TAB>title<TAB>description
    344 
    345 	# === FASE 1: paginas estaticas (pages/) ===
    346 	# genera el HTML del cuerpo de cada pagina y recolecta items del menu.
    347 	# el render completo se hace despues (fase 2) cuando ya tenemos el menu.
    348 	if [ -d "$PAGES" ]; then
    349 		for src in "$PAGES"/*.md "$PAGES"/*.html; do
    350 			[ -e "$src" ] || continue
    351 			name=${src##*/}; ext=${name##*.}; slug=${name%.*}
    352 
    353 			body="$tmp/p.$slug.body.src"; meta="$tmp/p.$slug.meta"
    354 			: > "$body"; : > "$meta"
    355 			parse_post "$src" "$body" "$meta"
    356 
    357 			if [ "$(meta_get draft "$meta")" = "true" ]; then
    358 				drafts=$((drafts + 1)); continue
    359 			fi
    360 			custom_slug=$(meta_get slug "$meta")
    361 			[ -n "$custom_slug" ] && slug="$custom_slug"
    362 
    363 			title=$(meta_get title "$meta")
    364 			[ -z "$title" ] && title="$slug"
    365 			desc=$(meta_get description "$meta")
    366 
    367 			if [ "$ext" = "html" ]; then
    368 				cp "$body" "$tmp/p.$slug.body.html"
    369 			else
    370 				"$md_cmd" < "$body" > "$tmp/p.$slug.body.html"
    371 			fi
    372 
    373 			# marcador en disco con metadatos de la pagina para fase 2
    374 			printf '%s\t%s\t%s\n' "$slug" "$title" "$desc" >> "$tmp/pages_list.txt"
    375 
    376 			# acumular en menu si procede
    377 			if [ "$(meta_get menu "$meta")" = "true" ]; then
    378 				order=$(meta_get menu_order "$meta")
    379 				[ -z "$order" ] && order=100
    380 				printf '%s\t%s\t%s\n' "$order" "$title" "$slug" \
    381 					>> "$tmp/menu_items.txt"
    382 			fi
    383 		done
    384 	fi
    385 
    386 	# construir HTML del menu a partir de menu_items.txt (orden numerico)
    387 	if [ -s "$tmp/menu_items.txt" ]; then
    388 		sort -n "$tmp/menu_items.txt" | while IFS='	' read -r o t s; do
    389 			printf '<a href="%s%s.html">%s</a>\n' \
    390 				"$site_baseurl" "$s" "$t"
    391 		done > "$tmp/menu.html"
    392 	fi
    393 
    394 	# === FASE 2: render final de paginas estaticas (ya con el menu) ===
    395 	if [ -s "$tmp/pages_list.txt" ]; then
    396 		while IFS='	' read -r slug title desc; do
    397 			pvars="$tmp/p.$slug.vars"
    398 			{ printf 'title\t%s\n' "$(sed_escape "$title")"; } > "$pvars"
    399 			cp "$(tpl page.html)" "$tmp/p.$slug.body"
    400 			while IFS='	' read -r k v; do
    401 				sub_var "$k" "$v" "$tmp/p.$slug.body"
    402 			done < "$pvars"
    403 			sub_file content "$tmp/p.$slug.body.html" "$tmp/p.$slug.body"
    404 			render_layout "$tmp/p.$slug.body" "$OUT/$slug.html" \
    405 				"$title — $site_title" "${desc:-$site_desc}"
    406 		done < "$tmp/pages_list.txt"
    407 	fi
    408 
    409 	# === FASE 3: posts ===
    410 	for src in "$POSTS"/*.md "$POSTS"/*.html; do
    411 		[ -e "$src" ] || continue
    412 		name=${src##*/}; ext=${name##*.}; slug=${name%.*}
    413 
    414 		body="$tmp/$slug.body.src"; meta="$tmp/$slug.meta"
    415 		: > "$body"; : > "$meta"
    416 		parse_post "$src" "$body" "$meta"
    417 
    418 		if [ "$(meta_get draft "$meta")" = "true" ]; then
    419 			drafts=$((drafts + 1)); continue
    420 		fi
    421 		custom_slug=$(meta_get slug "$meta")
    422 		[ -n "$custom_slug" ] && slug="$custom_slug"
    423 
    424 		title=$(meta_get title "$meta")
    425 		date=$(meta_get date "$meta")
    426 		desc=$(meta_get description "$meta")
    427 		tags_raw=$(meta_get tags "$meta")
    428 		[ -z "$title" ] && title="$slug"
    429 
    430 		if [ "$ext" = "html" ]; then
    431 			cp "$body" "$tmp/$slug.body.html"
    432 		else
    433 			"$md_cmd" < "$body" > "$tmp/$slug.body.html"
    434 		fi
    435 
    436 		# HTML de tags para mostrar en el propio post + acumular en tag_items
    437 		tags_html=""
    438 		if [ -n "$tags_raw" ]; then
    439 			tags_html='<span class="post-tags">'
    440 			for tg in $(split_tags "$tags_raw"); do
    441 				tags_html="$tags_html<a href=\"${site_baseurl}tags/${tg}.html\">$tg</a> "
    442 				printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
    443 					"$tg" "$date" "$slug" "$slug.html" "$title" "${desc:-}" \
    444 					>> "$tmp/tag_items.txt"
    445 			done
    446 			tags_html="${tags_html}</span>"
    447 		fi
    448 
    449 		# render post body
    450 		pvars="$tmp/$slug.pvars"
    451 		{
    452 			printf 'title\t%s\n' "$(sed_escape "$title")"
    453 			printf 'date\t%s\n'  "$(sed_escape "$date")"
    454 			printf 'tags\t%s\n'  "$(sed_escape "$tags_html")"
    455 		} > "$pvars"
    456 		cp "$(tpl post.html)" "$tmp/$slug.body"
    457 		while IFS='	' read -r k v; do
    458 			sub_var "$k" "$v" "$tmp/$slug.body"
    459 		done < "$pvars"
    460 		sub_file content "$tmp/$slug.body.html" "$tmp/$slug.body"
    461 
    462 		render_layout "$tmp/$slug.body" "$OUT/$slug.html" \
    463 			"$title — $site_title" "${desc:-$site_desc}"
    464 
    465 		printf '%s\t%s\t%s\t%s\t%s\n' "$date" "$slug" "$slug.html" "$title" "${desc:-}" \
    466 			>> "$tmp/index_items.txt"
    467 	done
    468 
    469 	# === FASE 4: indice (con paginacion) ===
    470 	sort -r "$tmp/index_items.txt" > "$tmp/index_items.sorted"
    471 
    472 	total=$(wc -l < "$tmp/index_items.sorted" | tr -d ' ')
    473 	if [ "$posts_per_page" -le 0 ] || [ "$total" -le "$posts_per_page" ]; then
    474 		# una sola pagina
    475 		render_index_page "$tmp/index_items.sorted" "Entradas" "" "" "$OUT/index.html"
    476 	else
    477 		mkdir -p "$OUT/page"
    478 		page=1; start=1
    479 		while [ "$start" -le "$total" ]; do
    480 			end=$((start + posts_per_page - 1))
    481 			[ "$end" -gt "$total" ] && end="$total"
    482 			chunk="$tmp/index_chunk_$page"
    483 			sed -n "${start},${end}p" "$tmp/index_items.sorted" > "$chunk"
    484 
    485 			# enlaces prev/next
    486 			prev=""
    487 			next=""
    488 			if [ "$page" -gt 1 ]; then
    489 				if [ "$page" -eq 2 ]; then
    490 					prev="<a href=\"${site_baseurl}\">&laquo; mas recientes</a>"
    491 				else
    492 					prev="<a href=\"${site_baseurl}page/$((page-1)).html\">&laquo; mas recientes</a>"
    493 				fi
    494 			fi
    495 			if [ "$end" -lt "$total" ]; then
    496 				next="<a href=\"${site_baseurl}page/$((page+1)).html\">mas antiguas &raquo;</a>"
    497 			fi
    498 
    499 			if [ "$page" -eq 1 ]; then
    500 				outf="$OUT/index.html"
    501 			else
    502 				outf="$OUT/page/$page.html"
    503 			fi
    504 			render_index_page "$chunk" "Entradas" "$prev" "$next" "$outf"
    505 
    506 			start=$((end + 1)); page=$((page + 1))
    507 		done
    508 	fi
    509 
    510 	# === FASE 5: pagina por tag ===
    511 	if [ -s "$tmp/tag_items.txt" ]; then
    512 		mkdir -p "$OUT/tags"
    513 		cut -f1 "$tmp/tag_items.txt" | sort -u > "$tmp/tags_unique.txt"
    514 		while IFS= read -r tag; do
    515 			awk -F'\t' -v t="$tag" 'BEGIN{OFS=FS} $1==t {print $2,$3,$4,$5,$6}' \
    516 				"$tmp/tag_items.txt" | sort -r > "$tmp/tag_$tag.sorted"
    517 			render_index_page "$tmp/tag_$tag.sorted" "Tag: $tag" "" "" \
    518 				"$OUT/tags/$tag.html"
    519 		done < "$tmp/tags_unique.txt"
    520 	fi
    521 
    522 	# === FASE 6: indice global de tags ===
    523 	if [ -s "$tmp/tag_items.txt" ]; then
    524 		: > "$tmp/tags_index.html"
    525 		cut -f1 "$tmp/tag_items.txt" | sort | uniq -c | sort -rn | \
    526 		while read -r count tag; do
    527 			printf '    <li><a href="%stags/%s.html">%s</a> <small>(%s)</small></li>\n' \
    528 				"$site_baseurl" "$tag" "$tag" "$count" >> "$tmp/tags_index.html"
    529 		done
    530 		cp "$(tpl tags.html)" "$tmp/tags.body"
    531 		sub_file items "$tmp/tags_index.html" "$tmp/tags.body"
    532 		render_layout "$tmp/tags.body" "$OUT/tags.html" \
    533 			"Tags — $site_title" "Indice de tags"
    534 	fi
    535 
    536 	# === FASE 7: feed atom ===
    537 	if [ -n "$site_feed" ]; then
    538 		feed_updated=""
    539 		read -r first < "$tmp/index_items.sorted" || true
    540 		if [ -n "$first" ]; then
    541 			d=$(printf '%s' "$first" | cut -f1)
    542 			feed_updated=$(to_rfc3339 "$d")
    543 		fi
    544 		[ -z "$feed_updated" ] && feed_updated=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    545 
    546 		fvars="$tmp/feed.vars"
    547 		{
    548 			printf 'title\t%s\n'        "$(sed_escape "$(xml_escape "$site_title")")"
    549 			printf 'description\t%s\n'  "$(sed_escape "$(xml_escape "$site_desc")")"
    550 			printf 'author\t%s\n'       "$(sed_escape "$(xml_escape "$site_author")")"
    551 			printf 'baseurl\t%s\n'      "$(sed_escape "$site_baseurl")"
    552 			printf 'siteurl\t%s\n'      "$(sed_escape "$site_url")"
    553 			printf 'feed\t%s\n'         "$(sed_escape "$site_feed")"
    554 			printf 'feed_updated\t%s\n' "$(sed_escape "$feed_updated")"
    555 		} > "$fvars"
    556 		render "$(tpl atom-header.xml)" "$fvars" "$tmp/feed.header"
    557 
    558 		: > "$tmp/feed.items"
    559 		while IFS='	' read -r d s u t desc; do
    560 			[ -z "$u" ] && continue
    561 			updated=$(to_rfc3339 "$d")
    562 			absurl="$site_url$site_baseurl$u"
    563 
    564 			evars="$tmp/e.vars"
    565 			{
    566 				printf 'title\t%s\n'       "$(sed_escape "$(xml_escape "$t")")"
    567 				printf 'absurl\t%s\n'      "$(sed_escape "$(xml_escape "$absurl")")"
    568 				printf 'updated\t%s\n'     "$(sed_escape "$updated")"
    569 				printf 'description\t%s\n' "$(sed_escape "$(xml_escape "$desc")")"
    570 			} > "$evars"
    571 			cp "$(tpl atom-item.xml)" "$tmp/e.out"
    572 			while IFS='	' read -r k v; do
    573 				sub_var "$k" "$v" "$tmp/e.out"
    574 			done < "$evars"
    575 			if [ -f "$tmp/$s.body.html" ]; then
    576 				sub_file content "$tmp/$s.body.html" "$tmp/e.out"
    577 			else
    578 				sub_var content "" "$tmp/e.out"
    579 			fi
    580 			cat "$tmp/e.out" >> "$tmp/feed.items"
    581 		done < "$tmp/index_items.sorted"
    582 
    583 		render "$(tpl atom-footer.xml)" "$fvars" "$tmp/feed.footer"
    584 		cat "$tmp/feed.header" "$tmp/feed.items" "$tmp/feed.footer" > "$OUT/$site_feed"
    585 	fi
    586 
    587 	# === FASE 8: imagenes (originales en images/ -> public/img/*) ===
    588 	process_images
    589 
    590 	# === FASE 9: static tal cual ===
    591 	if [ -d "$STATIC" ]; then
    592 		cp -r "$STATIC"/. "$OUT"/
    593 	fi
    594 
    595 	if [ "$drafts" -gt 0 ]; then
    596 		echo "build ok -> $OUT (saltados $drafts borradores)"
    597 	else
    598 		echo "build ok -> $OUT"
    599 	fi
    600 }
    601 
    602 # render_index_page items_file list_title prev_link next_link out_file
    603 # items_file: lineas date<TAB>slug<TAB>url<TAB>title<TAB>description
    604 render_index_page() {
    605 	items="$1"; ltitle="$2"; prev="$3"; next="$4"; outf="$5"
    606 
    607 	: > "$tmp/_items_html"
    608 	while IFS='	' read -r d s u t desc; do
    609 		[ -z "$u" ] && continue
    610 		ivars="$tmp/_i.vars"
    611 		{
    612 			printf 'date\t%s\n'        "$(sed_escape "$d")"
    613 			printf 'url\t%s\n'         "$(sed_escape "$site_baseurl$u")"
    614 			printf 'title\t%s\n'       "$(sed_escape "$t")"
    615 			printf 'description\t%s\n' "$(sed_escape "$desc")"
    616 		} > "$ivars"
    617 		render "$(tpl index-item.html)" "$ivars" "$tmp/_i.out"
    618 		cat "$tmp/_i.out" >> "$tmp/_items_html"
    619 	done < "$items"
    620 
    621 	cp "$(tpl index.html)" "$tmp/_index.body"
    622 	sub_var list_title "$(sed_escape "$ltitle")" "$tmp/_index.body"
    623 	pag="$prev $next"
    624 	sub_var pagination "$(sed_escape "$pag")" "$tmp/_index.body"
    625 	sub_file items "$tmp/_items_html" "$tmp/_index.body"
    626 
    627 	render_layout "$tmp/_index.body" "$outf" "$ltitle — $site_title" "$site_desc"
    628 }
    629 
    630 # --- entrada -------------------------------------------------------------
    631 
    632 case "${1:-build}" in
    633 	init)  cmd_init ;;
    634 	build) cmd_build ;;
    635 	clean) cmd_clean ;;
    636 	stats) cmd_stats ;;
    637 	serve) cmd_serve ;;
    638 	*) echo "uso: $0 [init|build|clean|stats|serve]" >&2; exit 1 ;;
    639 esac