translate.py shells out to git (rev-list, diff, add, commit, push) to find changed *.es.md files and push translated siblings back, but the python:3.12-slim image the translate step runs in doesn't include the git binary, so the pipeline failed immediately with: FileNotFoundError: [Errno 2] No such file or directory: 'git' Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |
||
|---|---|---|
| content | ||
| docs | ||
| infra | ||
| scripts | ||
| static | ||
| themes/vienalatina | ||
| .gitignore | ||
| .woodpecker.yml | ||
| config.yaml | ||
| README.md | ||
Viena Latina
Static, self-hosted, trilingual blog for the Latin American community in Vienna — Hugo + Decap CMS + Gitea + Woodpecker CI on a single Hetzner CX22 (Nuremberg, DE). Replaces the previous WordPress + Polylang + synchronous DeepL stack.
Pablo ──► Decap CMS (/admin) ──commit──► Gitea ──webhook──► Woodpecker CI
│
translate (DeepL, async) ─┤
build (hugo) ─┤
deploy (rsync) ─┘
▼
Caddy 2 serves /var/www/vienalatina.com
Languages
Spanish is the authoring language; German (/de/) and Brazilian Portuguese
(/pt-br/) siblings are generated by CI as reviewable git commits. Siblings
pair by filename basename:
content/post/mi-articulo.es.md ← authored in Decap
content/post/mi-articulo.de.md ← written by scripts/translate.py
content/post/mi-articulo.pt-br.md ← written by scripts/translate.py
Set manual_translation: true in a sibling's frontmatter to freeze it —
CI will never overwrite it again.
Frontmatter contract
---
title: "Mi artículo"
date: 2026-07-31
lang: es
manual_translation: false # set true on *.de.md/*.pt-br.md to freeze
categories: [Gastronomía]
---
Translation flow
- Publish
*.es.mdin Decap → one git commit, no waiting on DeepL. - Gitea webhook fires Woodpecker.
scripts/translate.pydiffs the push, translates changed*.es.mdvia DeepL (tag_handling=html, protected community terms), writes the siblings, and pushes them back as a bot commit (translations@vienalatina.com).hugo --minifybuilds,rsync --deletedeploys, Caddy serves.
Any DeepL error fails the pipeline visibly (red X, one-click retry) — no half-translated sets ever ship.
Server setup
Full command-by-command walkthrough: docs/server-setup.md. The Caddyfile and Docker Compose files it uses live in infra/.
Local development
hugo server # http://localhost:1313
CI secrets (Woodpecker → repo settings → secrets)
| Secret | Value |
|---|---|
deepl_api_key |
DeepL free-tier key (500k chars/mo) |
gitea_push_token |
Gitea token for the translations bot user, repo write access |
The repo must be marked trusted in Woodpecker so the deploy step can
mount /var/www/vienalatina.com.
Decap CMS
static/admin/config.yml uses the Gitea backend. Register an OAuth app in
Gitea admin (redirect URI https://vienalatina.com/admin/) and put its
client ID in app_id. The Decap JS bundle is downloaded at build time into
static/admin/decap-cms.js (gitignored) — zero third-party requests at
runtime.
GEO/SEO surfaces
hreflang+og:locale(:alternate)+ JSON-LDBlogPosting/BlogwithinLanguage—themes/vienalatina/layouts/partials/seo-head.htmlrobots.txtAI-crawler allowlist —static/robots.txtllms.txt— generated at build time fromlayouts/index.llms.txtsitemap.xml— Hugo native, multilingual
One-shot content migration
pip install requests html2text
python scripts/wp-to-hugo.py https://<old-wp-site>
Converts every WP post (with Polylang siblings) to
content/post/<slug>.<lang>.md, downloads images into static/uploads/,
and preserves existing slugs. Spot-check ~10 articles before committing.
Add Caddy 301s for WP URL patterns that don't map cleanly
(?p=123, /categoria/... → /categories/...).