# 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 ```yaml --- 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 1. Publish `*.es.md` in Decap → one git commit, no waiting on DeepL. 2. Gitea webhook fires Woodpecker. 3. `scripts/translate.py` diffs the push, translates changed `*.es.md` via DeepL (`tag_handling=html`, protected community terms), writes the siblings, and pushes them back as a bot commit (`translations@vienalatina.com`). 4. `hugo --minify` builds, `rsync --delete` deploys, 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](docs/server-setup.md)**. The Caddyfile and Docker Compose files it uses live in [infra/](infra/). ## Local development ```sh 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-LD `BlogPosting`/`Blog` with `inLanguage` — `themes/vienalatina/layouts/partials/seo-head.html` - `robots.txt` AI-crawler allowlist — `static/robots.txt` - `llms.txt` — generated at build time from `layouts/index.llms.txt` - `sitemap.xml` — Hugo native, multilingual ## One-shot content migration ```sh pip install requests html2text python scripts/wp-to-hugo.py https:// ``` Converts every WP post (with Polylang siblings) to `content/post/..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/...`).