From b96920c6bcf1b85affa1e1ad9bd77e0289ef4807 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 13:32:42 +0000 Subject: [PATCH 1/5] Enable the vienalatina.com site block in the Caddyfile 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 Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn --- infra/caddy/Caddyfile | 96 +++++++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile index 43df2d8..b71d78d 100644 --- a/infra/caddy/Caddyfile +++ b/infra/caddy/Caddyfile @@ -1,9 +1,9 @@ # /etc/caddy/Caddyfile — copy this file there, then: sudo systemctl reload caddy # # Caddy fetches and renews Let's Encrypt certificates automatically the first -# time a domain's DNS points at this server. git.* and ci.* work immediately; -# the vienalatina.com block stays commented until cutover day (Sunday evening), -# because Caddy can only get its certificate once the A record points here. +# time a domain's DNS points at this server. Order matters on a cutover: move +# the A record here first, then reload Caddy — the ACME HTTP-01 challenge for +# vienalatina.com only succeeds once the domain already resolves to this box. git.vienalatina.com { reverse_proxy 127.0.0.1:3000 @@ -14,50 +14,50 @@ ci.vienalatina.com { } # --------------------------------------------------------------------------- -# UNCOMMENT EVERYTHING BELOW ON CUTOVER DAY (after flipping the A record) +# Live since cutover (A record for vienalatina.com points at this server). # --------------------------------------------------------------------------- -# www.vienalatina.com { -# redir https://vienalatina.com{uri} 301 -# } -# -# vienalatina.com { -# root * /var/www/vienalatina.com -# encode zstd gzip -# file_server -# -# # llms.txt is markdown (matches the old WP behaviour) -# header /llms.txt Content-Type "text/markdown; charset=utf-8" -# -# # --- 301s for old WordPress URL patterns ----------------------------- -# -# # /?p=123 style permalinks → home (add specific mappings as GSC reports them) -# @wp_query_permalink { -# path / -# query p=* -# } -# redir @wp_query_permalink / 301 -# -# # WP category base was /categoria/… (Spanish slugs); Hugo uses /categories/… -# @old_category path_regexp oldcat ^/categoria/(.*)$ -# redir @old_category /categories/{re.oldcat.1} 301 -# -# # WP author archives have no Hugo equivalent → home -# @old_author path /autor/* -# redir @old_author / 301 -# -# # Old WP media library URLs → migrated uploads folder -# @old_uploads path_regexp oldup ^/wp-content/uploads/(?:\d{4}/\d{2}/)?(.*)$ -# redir @old_uploads /uploads/{re.oldup.1} 301 -# -# # Anything else that starts with /wp- doesn't exist anymore -# @wp_leftovers path /wp-admin/* /wp-login.php /wp-json/* /xmlrpc.php -# redir @wp_leftovers / 301 -# -# # Custom 404 falls back to Hugo's 404 page -# handle_errors { -# @404 expression {err.status_code} == 404 -# rewrite @404 /404.html -# file_server -# } -# } +www.vienalatina.com { + redir https://vienalatina.com{uri} 301 +} + +vienalatina.com { + root * /var/www/vienalatina.com + encode zstd gzip + file_server + + # llms.txt is markdown (matches the old WP behaviour) + header /llms.txt Content-Type "text/markdown; charset=utf-8" + + # --- 301s for old WordPress URL patterns ----------------------------- + + # /?p=123 style permalinks → home (add specific mappings as GSC reports them) + @wp_query_permalink { + path / + query p=* + } + redir @wp_query_permalink / 301 + + # WP category base was /categoria/… (Spanish slugs); Hugo uses /categories/… + @old_category path_regexp oldcat ^/categoria/(.*)$ + redir @old_category /categories/{re.oldcat.1} 301 + + # WP author archives have no Hugo equivalent → home + @old_author path /autor/* + redir @old_author / 301 + + # Old WP media library URLs → migrated uploads folder + @old_uploads path_regexp oldup ^/wp-content/uploads/(?:\d{4}/\d{2}/)?(.*)$ + redir @old_uploads /uploads/{re.oldup.1} 301 + + # Anything else that starts with /wp- doesn't exist anymore + @wp_leftovers path /wp-admin/* /wp-login.php /wp-json/* /xmlrpc.php + redir @wp_leftovers / 301 + + # Custom 404 falls back to Hugo's 404 page + handle_errors { + @404 expression {err.status_code} == 404 + rewrite @404 /404.html + file_server + } +} From 6890ff4153e709c1d57791f633403e44908f7304 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 13:57:17 +0000 Subject: [PATCH 2/5] Document the Decap OAuth app as a public PKCE client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn --- docs/server-setup.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/server-setup.md b/docs/server-setup.md index e47358a..d457fd5 100644 --- a/docs/server-setup.md +++ b/docs/server-setup.md @@ -146,8 +146,15 @@ Then create the two OAuth apps and the bot user, all in the Gitea web UI: - Save the **Client ID** and **Client Secret** — needed in step 7. 2. **Decap OAuth app:** same screen, second application - Name: `decap-cms` - - Redirect URI: `https://vienalatina.com/admin/` + - Redirect URI: `https://vienalatina.com/admin/` (exactly, trailing slash + included — Gitea matches it literally) + - **Untick "Confidential Client".** Decap runs in the browser and + authenticates with PKCE; a confidential app makes Gitea demand a client + secret that a browser cannot keep, and the login fails *after* you + authorize, which makes it look like a Decap bug. - Save the **Client ID** — it goes into `static/admin/config.yml` (step 9). + There is no secret to save, and the Client ID is not one either: it is + published in the site's JavaScript by design. 3. **Translations bot:** Site Administration → Identity & Access → User Accounts → *Create User Account* - Username: `translations`, email: `translations@vienalatina.com`, @@ -225,6 +232,21 @@ On your working copy: edit `static/admin/config.yml`, replace step 6.2, commit, push to Gitea. (You can't log into `/admin` until the main domain is live — that's expected.) +```sh +cd ~/vienalatina +sed -i 's/REPLACE_WITH_GITEA_OAUTH_CLIENT_ID//' static/admin/config.yml +grep app_id static/admin/config.yml +git commit -am "Wire Decap to the Gitea OAuth app" && git push gitea main +``` + +This value is per-deployment: the placeholder is what belongs in the repo, so +leave it in place in any copy of this platform that is not this server. + +If `/admin/` still shows *Client ID not registered* afterwards, the page is +serving a cached `config.yml` — hard-reload it. If it fails *after* the Gitea +authorize screen instead, the app was created as a confidential client; delete +it and recreate it with that box unticked. + ## 10. Test the translation loop end-to-end ```sh From b69e9d6f81847a945f08405dcc2317232c2cf87a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 14:17:44 +0000 Subject: [PATCH 3/5] Let Gitea answer Decap's cross-origin token request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn --- infra/gitea/docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/infra/gitea/docker-compose.yml b/infra/gitea/docker-compose.yml index af12bde..1caa404 100644 --- a/infra/gitea/docker-compose.yml +++ b/infra/gitea/docker-compose.yml @@ -14,6 +14,15 @@ services: - GITEA__server__SSH_DOMAIN=git.vienalatina.com - GITEA__service__DISABLE_REGISTRATION=true - GITEA__webhook__ALLOWED_HOST_LIST=ci.vienalatina.com + # Decap is served from vienalatina.com but exchanges its OAuth code for a + # token by calling git.vienalatina.com from the browser — a cross-origin + # request. Without this, Gitea returns no Access-Control-Allow-Origin, the + # browser drops the response, and Decap reports "TypeError: Failed to + # fetch" right after you authorize, which reads like a Decap bug. + - GITEA__cors__ENABLED=true + - GITEA__cors__ALLOW_DOMAIN=https://vienalatina.com + - GITEA__cors__METHODS=GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS + - GITEA__cors__HEADERS=Content-Type,User-Agent,Authorization volumes: - ./data:/data - /etc/timezone:/etc/timezone:ro From 67706ee36e8d5d0823e48f82e270553e5068b3e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 07:49:46 +0000 Subject: [PATCH 4/5] Stop two posts with the same title from sharing one URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn --- .woodpecker.yml | 8 +++++++- config.yaml | 9 +++++++-- scripts/translate.py | 16 ++++++++++++++++ static/admin/config.yml | 7 ++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index eca2f89..f0a0447 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -42,7 +42,13 @@ steps: commands: # Bundle Decap locally so the /admin page makes zero third-party requests. - wget -q -O static/admin/decap-cms.js https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js - - hugo --minify + # --printPathWarnings makes Hugo report two pages resolving to one output + # path; --panicOnWarning turns that report into a failed build. Without + # both, Hugo writes both documents into the same index.html and the site + # ships a page that renders as the article followed by a second copy of + # the whole site — with nothing in the CMS, the pipeline or the logs + # saying a word about it. Better a red build than a corrupted page. + - hugo --minify --printPathWarnings --panicOnWarning deploy: image: alpine:3.20 diff --git a/config.yaml b/config.yaml index e180dac..f2bf9e5 100644 --- a/config.yaml +++ b/config.yaml @@ -9,9 +9,14 @@ theme: "vienalatina" defaultContentLanguage: "es" defaultContentLanguageInSubdir: false -# Preserve WordPress slugs so external inbound links keep working. +# Date-scoped so two posts can share a title without fighting over one URL. +# `:slug` falls back to the title when front matter sets none, so a bare +# "/:slug/" mapped every post called "Hola mundo" onto /hola-mundo/; Hugo wrote +# both documents into that one index.html and the page rendered as two stacked +# copies of the site, with no error anywhere. Community blogs repeat titles +# constantly ("Resumen del mes"), so the year and month carry the uniqueness. permalinks: - post: "/:slug/" + post: "/:year/:month/:slug/" taxonomies: category: "categories" diff --git a/scripts/translate.py b/scripts/translate.py index 76d7a24..724fbb5 100644 --- a/scripts/translate.py +++ b/scripts/translate.py @@ -78,6 +78,14 @@ def split_lang(path: Path) -> tuple[str, str] | None: return None +# Decap resolves a filename clash by appending a counter, turning +# `hola-mundo.es.md` into `hola-mundo.es-1.md`. That name no longer ends in a +# language, so Hugo stops pairing it with its siblings and split_lang() returns +# None: the post publishes in Spanish and is never translated, with nothing +# anywhere reporting it. Recognising the shape lets us fail loudly instead. +CLASH_SUFFIX = re.compile(r"\.(?:%s)-\d+$" % "|".join(re.escape(l) for l in SITE_LANGS)) + + def split_frontmatter(text: str) -> tuple[dict, str]: match = re.match(r"\A---\n(.*?)\n---\n?(.*)\Z", text, re.DOTALL) if not match: @@ -130,6 +138,14 @@ def authored_sources(paths: list[Path]) -> list[tuple[Path, str, str]]: for path in paths: parsed = split_lang(path) if not parsed: + rel = path.relative_to(REPO_ROOT) + if CLASH_SUFFIX.search(path.name[: -len(".md")] if path.suffix == ".md" else ""): + raise SystemExit( + f"{rel}: filename ends in a clash counter, so it is neither a " + f"translation source nor a sibling — it would publish untranslated.\n" + f"Rename it to ..md (the CMS produced this " + f"because another post already claimed the name)." + ) continue basename, lang = parsed fm = read_frontmatter(path) diff --git a/static/admin/config.yml b/static/admin/config.yml index 95d0215..58c970c 100644 --- a/static/admin/config.yml +++ b/static/admin/config.yml @@ -22,7 +22,12 @@ collections: create: true extension: md format: yaml-frontmatter - slug: "{{slug}}.es" + # Date-prefixed so a repeated title does not collide on the filename. When + # it does collide, Decap appends "-1" and writes `hola-mundo.es-1.md` — a + # name whose suffix is no longer `.es`, so Hugo stops seeing a Spanish + # sibling and translate.py's split_lang() skips the file entirely: the post + # publishes but is never translated, with nothing reported anywhere. + slug: "{{year}}-{{month}}-{{day}}-{{slug}}.es" filter: { field: lang, value: es } fields: - { name: title, label: "Título", widget: string } From 90c22cf02cd8aedb2508b8aebe98020c060b022a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 07:50:51 +0000 Subject: [PATCH 5/5] Scope post URLs to the day, not the month 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 Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn --- config.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/config.yaml b/config.yaml index f2bf9e5..05bbbed 100644 --- a/config.yaml +++ b/config.yaml @@ -14,9 +14,12 @@ defaultContentLanguageInSubdir: false # "/:slug/" mapped every post called "Hola mundo" onto /hola-mundo/; Hugo wrote # both documents into that one index.html and the page rendered as two stacked # copies of the site, with no error anywhere. Community blogs repeat titles -# constantly ("Resumen del mes"), so the year and month carry the uniqueness. +# constantly ("Resumen del mes"), so the date carries the uniqueness. The day is +# part of it because the two posts that exposed this were eleven days apart in +# the same month; only same-title-same-day now collides, and the build guard in +# .woodpecker.yml fails that loudly instead of shipping it. permalinks: - post: "/:year/:month/:slug/" + post: "/:year/:month/:day/:slug/" taxonomies: category: "categories"