|
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
# Conflicts: # .woodpecker.yml |
||
|---|---|---|
| content | ||
| docker/translate | ||
| 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.
Translation is self-hosted too: M2M100 418M (MIT) runs on CPU via CTranslate2 inside the pipeline image. No API key, no quota, and no visitor or content data leaving the server.
Pablo ──► Decap CMS (/admin) ──commit──► Gitea ──webhook──► Woodpecker CI
│
translate (M2M100, async) ──┤
build (hugo) ─┤
deploy (rsync) ─┘
▼
Caddy 2 serves /var/www/vienalatina.com
Languages
Spanish, German (/de/) and Brazilian Portuguese (/pt-br/). Any of the
three can be the authored original; the other two are generated by CI as
reviewable git commits. Siblings pair by filename basename:
content/post/mi-articulo.es.md ← authored (no `translated_from`)
content/post/mi-articulo.de.md ← written by scripts/translate.py
content/post/mi-articulo.pt-br.md ← written by scripts/translate.py
A generated file carries translated_from, which is what stops CI translating
its own output back into a loop. Machine output is never treated as a source.
manual_translation: true means "hands off", on both sides:
- on an authored original — don't generate siblings for this post at all
- on a generated sibling — never overwrite it again
Frontmatter contract
---
title: "Mi artículo"
date: 2026-07-31
lang: es
manual_translation: false
categories: [Gastronomía] # taxonomy terms stay Spanish in every language
---
Generated siblings additionally carry translated_from: es.
Translation flow
- Publish in Decap → one git commit.
- Gitea webhook fires Woodpecker.
scripts/translate.pydiffs the push, finds changed authored files in any language, generates the missing siblings, and pushes them back as a bot commit (translations@vienalatina.com) marked[skip-translate].hugo --minifybuilds,rsync --deletedeploys, Caddy serves.
Markup never reaches the model: code blocks and raw HTML pass through
untouched, and link targets, inline code and protected community terms
(Grätzl, Naschmarkt, …) are masked and verified to survive the round trip.
A mask that doesn't come back fails the pipeline rather than shipping corrupted
text — no half-translated sets ever ship.
Backfill anything missing siblings (after the WP migration, or for pages that predate the pipeline):
python scripts/translate.py --backfill
Translation engine
Built once on the server, and again only when changing models:
docker build -t vienalatina/translate:1 docker/translate
The image bakes in a pre-converted CTranslate2 build of M2M100 418M plus its
tokenizer, so a publish makes no network calls. Tunable via MT_MODEL_DIR,
MT_TOKENIZER, MT_COMPUTE_TYPE and MT_THREADS.
Swapping engines means writing one class against
scripts/translation/provider.py — nothing in the pipeline changes. Two
upgrades worth knowing about:
- M2M100 1.2B — same MIT licence and same code path, materially better output, but ~2–2.5GB peak RAM. Needs an 8GB box (Hetzner CX32), not the CX22.
- opus-mt-tc-big — better still for these specific language pairs and the
only permissive option that handles Brazilian Portuguese distinctly (
>>pob<<), at the cost of CC-BY-4.0 attribution and one model per directed pair.
Do not build on NLLB-200 (CC-BY-NC, non-commercial) or LibreTranslate (AGPL-3.0) if this stack is ever to be sold or offered as a service.
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 |
|---|---|
gitea_push_token |
Gitea token for the translations bot user, repo write access |
Translation needs no secret — the model is local.
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/...).