Commit Graph

5 Commits

Author SHA1 Message Date
Claude
67706ee36e
Stop two posts with the same title from sharing one URL
Publishing a second "Hola mundo" through the CMS broke the first one's page:
it rendered the article, then a second copy of the entire site. Three silent
failures lined up.

Decap could not write hola-mundo.es.md twice, so it wrote hola-mundo.es-1.md.
That suffix is not a language, so Hugo stopped treating the file as a Spanish
sibling and translate.py's split_lang() skipped it — the post went live in
Spanish alone, and no German or Portuguese was ever generated. Meanwhile
permalinks used "/:slug/", and :slug falls back to the title, so both files
claimed /hola-mundo/; Hugo wrote both documents into that one index.html.
Nothing failed. The pipeline was green throughout.

Each layer now refuses its part: post permalinks carry the year and month, the
CMS prefixes new filenames with the date, and translate.py aborts on a name
ending in a clash counter rather than quietly declining to translate it. The
build also runs with --printPathWarnings --panicOnWarning, so any future pair
of pages targeting one path fails the build instead of corrupting the output.

Existing post URLs change shape (/hola-mundo/ becomes /2026/09/hola-mundo/).
That costs nothing today, with one real post and no inbound links, and gets
expensive to change later.

Verified: 16/16 pipeline checks and 15/15 markdown checks still pass, the
clash-counter name aborts with the rename instruction, and an ordinary
filename still parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-22 07:49:46 +00:00
Claude
24dbc9b961
Switch translation to OPUS-MT; mount models instead of baking them
M2M100 418M failed the quality gate on real articles. Measured on a 420-word
post: "frijoles" came back as "Beeren" (berries), "rompe la idea" as "breitet
die Idee" (spreads it — the opposite), "así sabe mi barrio" as "so weiß mein
Viertel" (knows, not tastes), and the opening sentence was not grammatical
German. Invented non-words throughout ("verforscht", "Treffenraum").

The pipeline itself was never at fault — masking held, structure survived,
zero placeholder failures across 14 files. The model was.

OPUS-MT is stronger on these specific pairs, and fits the existing CX22 with
no server upgrade, which was the constraint. Licence moves from MIT to
CC-BY-4.0, so attribution now ships with the platform.

Routes are verified against Hugging Face rather than assumed — the earlier
research could not reach HF, and half the names it guessed do not exist:

  es -> de       opus-mt-es-de           (small, ~74M)
  de -> es       opus-mt-tc-big-de-es    (tc-big, ~237M)
  es <-> pt-br   opus-mt-tc-big-itc-itc  (>>pob<< / >>spa<<)
  de <-> pt-br   no model in either direction — pivots through Spanish

Models now live at /srv/mt-models and are mounted read-only rather than baked
into the image. Model choice has needed iteration, and a directory swap beats
a 1GB image rebuild each time. It also keeps model conversion out of the image
build, which matters on a box that OOM-killed the last conversion.

MT_PROVIDER=m2m100 still selects the old engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-18 12:41:45 +00:00
Claude
986c066ae3
Replace DeepL with a self-hosted translation engine
The DeepL free tier is metered (it failed in production with HTTP 456
Quota exceeded) and would require every deployment of this platform to
carry its own API account. Translation now runs on M2M100 418M (MIT) via
CTranslate2, shipped inside the pipeline image: no key, no quota, and no
content or visitor data leaving the server.

Also restores the multi-source behaviour of the original WordPress plugin,
which the Python port had narrowed to Spanish-only. Any of the three site
languages can now be the authored original.

Because any language can be a source, loop prevention is no longer
structural and is now explicit: generated siblings carry `translated_from`
and are never treated as sources, and the bot's own [skip-translate]
commits are skipped outright (that marker was already being written but
never read).

Markup protection moves in-process now that DeepL's tag_handling=html is
gone. Code blocks and raw HTML pass through untouched; link targets,
inline code and protected community terms are masked with placeholders
that are verified to survive the round trip, failing the pipeline rather
than shipping corrupted text.

Two fixes along the way:

- Generated siblings no longer inherit the source's `slug`. They did,
  which meant the first retranslation of a WordPress-migrated post moved
  /de/<german-slug>/ onto /de/<spanish-slug>/ and destroyed the inbound
  link preservation wp-to-hugo.py exists for.
- `manual_translation` now works from the CMS. Decap only ever exposed it
  on the source while the script read it on the target, so the toggle did
  nothing. It now means "hands off" on both sides.

wp-to-hugo.py marks migrated Polylang siblings frozen, since those are
human translations and regenerating them would replace them with weaker
machine output.

Adds --backfill for sources missing siblings, which also fixes the
existing 404s on /de/page/acerca/ and /pt-br/page/contacto/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-14 16:34:25 +00:00
Claude
adeecb5e92
Install git in the translate step's container
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
2026-09-14 14:30:20 +00:00
Claude
72894b8907
Scaffold Hugo + Decap trilingual site from WordPress migration plan
Static replacement for the WordPress + Polylang + synchronous DeepL stack:

- Hugo config: es (default) / de / pt-br, siblings paired by filename
  basename, WP slugs preserved via /:slug/ permalinks
- Theme: CSS ported verbatim from the WP theme; header/footer/lang-switcher
  and article templates ported from their PHP counterparts (no Google
  Fonts — zero third-party requests)
- seo-head partial: hreflang + x-default, og:locale(:alternate), JSON-LD
  BlogPosting/Blog with inLanguage (ported from theme/inc/seo.php)
- robots.txt AI-crawler allowlist + build-time llms.txt (ported from
  theme/inc/robots-and-llms.php)
- scripts/translate.py: async DeepL step (tag_handling=html, protected
  community terms, manual_translation freeze, fail-loud, bot commits)
- scripts/wp-to-hugo.py: one-shot WP REST → Hugo content converter
- Decap CMS admin with Gitea backend, locally-bundled JS
- .woodpecker.yml: translate → build → deploy (rsync to Caddy root)
- Sample trilingual welcome post + acerca/contacto pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017z2rT1oN7vMS4ggo23n5WG
2026-07-31 11:17:32 +00:00