Go to file
Claude da8cc784be
Remove generated siblings when their source is deleted
Deleting a post in Decap removes one file: the Spanish original. The German
and Portuguese versions were written by this script, so nothing else deletes
them, and they stayed on the site as posts in a language with no original —
/de/ and /pt-br/ kept listing articles that no longer exist in Spanish.

The pipeline had no concept of removal at all. changed_markdown() filters the
push diff to A and M, so a deletion never reached the translate step; a push
that only deletes posts arrived with no sources and returned early, which is
precisely the case that leaves siblings stranded.

Siblings are now reaped by scanning the content tree for a translated_from
whose source file is gone, which also clears debris from earlier runs and from
renames. A frozen sibling is reported rather than deleted: someone edited that
translation by hand, and throwing the work away on an inference is worse than
leaving one stale page until they remove it themselves. The reaper runs even
when nothing was translated, and commit_and_push stages with --all so a
vanished path commits as a deletion.

Verified: reaps both siblings of a deleted source, keeps siblings whose source
is alive, keeps a frozen orphan, never touches a human-authored original.
16/16 pipeline and 15/15 markdown checks still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-22 09:49:20 +00:00
content Scaffold Hugo + Decap trilingual site from WordPress migration plan 2026-07-31 11:17:32 +00:00
docker/translate Switch translation to OPUS-MT; mount models instead of baking them 2026-09-18 12:41:45 +00:00
docs Document the Decap OAuth app as a public PKCE client 2026-09-18 13:57:17 +00:00
infra Let Gitea answer Decap's cross-origin token request 2026-09-18 14:17:44 +00:00
scripts Remove generated siblings when their source is deleted 2026-09-22 09:49:20 +00:00
static Stop two posts with the same title from sharing one URL 2026-09-22 07:49:46 +00:00
themes/vienalatina Scaffold Hugo + Decap trilingual site from WordPress migration plan 2026-07-31 11:17:32 +00:00
.gitignore Scaffold Hugo + Decap trilingual site from WordPress migration plan 2026-07-31 11:17:32 +00:00
.woodpecker.yml Stop two posts with the same title from sharing one URL 2026-09-22 07:49:46 +00:00
config.yaml Scope post URLs to the day, not the month 2026-09-22 07:50:51 +00:00
README.md Degrade instead of failing when a mask is lost in translation 2026-09-16 09:46:05 +00:00

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

  1. Publish in Decap → one git commit.
  2. Gitea webhook fires Woodpecker.
  3. scripts/translate.py diffs 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].
  4. hugo --minify builds, rsync --delete deploys, 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.

Small models drop those masks occasionally, so a failed round trip degrades in steps rather than failing the publish:

  1. mask markup and protected terms — the normal path;
  2. if a term is lost, retry guarding only markup, and log that the term may now be translated;
  3. if markup itself is lost, leave that segment in the source language and warn.

A stray ⦅0⦆ or a mangled URL therefore never reaches a reader, and one awkward proper noun never blocks a deploy.

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-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

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/...).