Decap looks like a different product because it cannot be made to look like
this one: its maintainer's answer is override the CSS and accept that class
names move between releases, or fork it and carry that forever. Neither is
worth building on, so editing moves into the app that already has the site's
design and this project's tests.
Posts are files in git, so the editor is a form that commits a file through
Gitea's contents API. No working copy, no queue, no new state, and no way for
the pipeline to tell which editor wrote the file — which is what makes running
both at once safe. Decap keeps working; nothing here removes it, and the
fallback if something is missing is switching tabs.
Filenames and frontmatter are not this module's to invent. They are the
contract scripts/translate.py reads: `<basename>.es.md` is what split_lang()
parses, the date prefix is what keeps two posts with one title off a single
URL, and the absence of `translated_from` is what marks a file as something a
person wrote. Several tests import translate.py and run its own parser over
what the editor produced, because a file it cannot parse publishes in Spanish
and is never translated, with nothing reported anywhere.
Commits carry the writer's own Gitea account rather than a bot's, so history
says who wrote each post and Gitea's permissions apply unchanged. That needs
their access token, which lives in the database and never in a cookie: Flask
signs cookies but does not encrypt them, and a token is enough to commit as its
owner. Gitea expires tokens after about an hour, so they refresh ahead of
expiry and retry once on rejection — without that, saving would start failing
partway through an afternoon for no reason the writer could see.
Publishing is admin-only. Posting to the internal board and publishing to the
public site are different permissions, and the second is the larger grant.
Listing caches frontmatter against the git blob sha, because the contents API
returns names without bodies: that turns one request per post on every page
load into one request in total, and needs no invalidation, since a sha changes
only when the file does.
Verified: 97 checks. The filename translate.py parses, an authored source that
is_generated() rejects, the freeze toggle round-tripping, a stale sha refused
with the other edit intact, six filename shapes that must 404 including a
generated sibling, plain members refused, an SVG rejected as an image, uploads
not colliding, the cache reading each file once and refreshing when it changes,
and a preview that renders markdown, escapes script tags and publishes nothing.
Smoke-tested live: routes register and gate correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
Everything on this site so far has been a file built from git. This is the
first component that runs code to answer a request and the first whose data
git does not hold, so the trade is stated in the README and the backup script
is not optional.
Roles are owner, admin and user. The owner is seeded once from BOARD_OWNER and
cannot be seeded again, because otherwise editing a compose file would be a
quieter way to take the top role than asking for it; ownership moves only by
transfer, inside the app. There is exactly one owner and a partial unique index
enforces it, so the invariant holds even when a handler is wrong. The owner is
beyond suspension and demotion by everyone, themselves included. Only the owner
makes admins; admins make users.
Sign-in goes through Gitea as a confidential OAuth client — the opposite of
Decap, which has to be public because it runs in the browser. The rule the
whole thing rests on is that a Gitea account is not a membership: entry needs
an active row in `members`, or every account on the instance is a member,
starting with the translations bot.
Admins can delete any post; nobody can edit anyone else's, admins included.
Taking a post down is visible to its author. Quietly rewriting it is not, and
an admin who could do that could leave a sentence attributed to someone who
never wrote it. The plan said admins could do both; this is the one place the
implementation departs from it.
Markdown renders with raw HTML disabled, which is the entire XSS defence and
the reason there is no sanitiser: the renderer emits only its own tags and
escapes the rest. The CSP carries no 'unsafe-inline', which makes an inline
onsubmit silently inert rather than broken, so the confirmation dialogs live in
a static file and a test fails any template that grows an inline handler.
GDPR is in scope rather than deferred: erasure removes the member row and moves
their authorship to a tombstone so the conversations around them still read,
and any member can download their own writing.
Verified: 63 checks pass, covering the membership gate, every role predicate, a
direct insert of a second owner being refused by the index, atomic ownership
transfer, CSRF, an offsite login redirect, script tags rendering as text, soft
deletes leaving both listings and exports, and the member screens rendering for
each role. Smoke-tested live: headers, both static assets, and the bare
/comunidad redirect that the Caddy matcher has to cover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
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
changed_markdown() filtered the push diff to A and M. Git classifies a rename
as R, so the file renamed last night never reached the translate step: the post
published in Spanish alone, and because the language switcher only renders when
.IsTranslated is true, its page shows no ES/DE/PT links at all.
--no-renames makes git report the same change as a delete plus an add, and the
add is an ordinary translation source. Verified against a real repository: the
old filter returned nothing for a rename, the new one returns the new path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
Month granularity would not have separated the two posts that exposed this
bug: they are dated 18 and 21 September 2026, so both still resolved to
/2026/09/hola-mundo/ and the page would have stayed broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
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
Decap is served from vienalatina.com and posts its OAuth code to
git.vienalatina.com/login/oauth/access_token from the browser. Gitea disables
CORS by default, so the browser discarded the response and Decap surfaced it
as "TypeError: Failed to fetch" after a successful authorize — the login looks
broken at the last step, with nothing wrong on either side individually.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
Gitea ticks "Confidential Client" by default, which makes it demand a client
secret. Decap runs in the browser and authenticates with PKCE, so there is
nowhere to keep one — the login then fails after the authorize screen, which
reads like a Decap bug rather than a registration mistake.
Also spells out step 9 as commands, notes that the Client ID is published in
the site's JavaScript by design (so committing it is fine, but the placeholder
is what belongs in a resold copy), and records the two failure modes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
The block was parked behind a comment marker until the domain's A record
pointed at this server. It does now, so the checked-in config should match
what is actually serving; leaving it commented meant a redeploy from the
repo would silently take the site down.
Also corrects the header note to say why the order is DNS-then-reload: the
ACME HTTP-01 challenge for vienalatina.com can only pass once the domain
already resolves here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
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
Measured against M2M100 418M on real content: the OpenNMT ⦅0⦆ convention was
dropped on every single occurrence of a protected term. SentencePiece
fragments punctuation runs, and the model then has nothing it recognises as a
unit to copy across.
The brand paid for it. "Viena Latina" came back as "Wien Latin" in one file
and "Vienna Latina" in another — the same name rendered two different wrong
ways across two pages, which is worse than being consistently wrong. "Grätzl"
survived untouched throughout, because it is genuinely unknown to the model,
whereas "Viena" reads as a city name and gets translated.
Masks are now word-shaped (Zq0Xv), which a model treats like an unknown proper
noun and carries through rather than translating. Matching is case- and
space-insensitive, since models re-case and pad these.
The tiered fallback stays as the safety net for when it is still dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
First real pipeline run died with:
PlaceholderError: masked span 'Viena Latina' came back 0 times
M2M100 drops placeholder tokens often enough that failing the pipeline on
mismatch would block the whole site deploy over a single proper noun. The
verification itself was right — it stopped a literal ⦅0⦆ reaching a
reader — but the policy was too blunt.
Masks are now tiered by how much they actually matter. Markup must survive;
terminology is a preference. So: try markup + terms, and on a lost term retry
guarding only markup, accepting the term may come back translated. Only if
markup itself is lost does the segment stay in the source language.
A stray placeholder or mangled URL still never reaches a reader, but one
awkward proper noun no longer blocks a publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
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
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
- infra/caddy/Caddyfile: git.* and ci.* proxies live now; main-site block
(with WP 301 redirects) commented until cutover day
- infra/gitea and infra/woodpecker: Docker Compose bound to localhost
behind Caddy, with .env.example for the Woodpecker OAuth credentials
- docs/server-setup.md: every command from ordering the CX22 through the
end-to-end translation test, plus the cutover-day checklist
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017z2rT1oN7vMS4ggo23n5WG