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
This commit is contained in:
parent
b69e9d6f81
commit
67706ee36e
@ -42,7 +42,13 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
# Bundle Decap locally so the /admin page makes zero third-party requests.
|
# 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
|
- 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:
|
deploy:
|
||||||
image: alpine:3.20
|
image: alpine:3.20
|
||||||
|
|||||||
@ -9,9 +9,14 @@ theme: "vienalatina"
|
|||||||
defaultContentLanguage: "es"
|
defaultContentLanguage: "es"
|
||||||
defaultContentLanguageInSubdir: false
|
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:
|
permalinks:
|
||||||
post: "/:slug/"
|
post: "/:year/:month/:slug/"
|
||||||
|
|
||||||
taxonomies:
|
taxonomies:
|
||||||
category: "categories"
|
category: "categories"
|
||||||
|
|||||||
@ -78,6 +78,14 @@ def split_lang(path: Path) -> tuple[str, str] | None:
|
|||||||
return 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]:
|
def split_frontmatter(text: str) -> tuple[dict, str]:
|
||||||
match = re.match(r"\A---\n(.*?)\n---\n?(.*)\Z", text, re.DOTALL)
|
match = re.match(r"\A---\n(.*?)\n---\n?(.*)\Z", text, re.DOTALL)
|
||||||
if not match:
|
if not match:
|
||||||
@ -130,6 +138,14 @@ def authored_sources(paths: list[Path]) -> list[tuple[Path, str, str]]:
|
|||||||
for path in paths:
|
for path in paths:
|
||||||
parsed = split_lang(path)
|
parsed = split_lang(path)
|
||||||
if not parsed:
|
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 <something-unique>.<lang>.md (the CMS produced this "
|
||||||
|
f"because another post already claimed the name)."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
basename, lang = parsed
|
basename, lang = parsed
|
||||||
fm = read_frontmatter(path)
|
fm = read_frontmatter(path)
|
||||||
|
|||||||
@ -22,7 +22,12 @@ collections:
|
|||||||
create: true
|
create: true
|
||||||
extension: md
|
extension: md
|
||||||
format: yaml-frontmatter
|
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 }
|
filter: { field: lang, value: es }
|
||||||
fields:
|
fields:
|
||||||
- { name: title, label: "Título", widget: string }
|
- { name: title, label: "Título", widget: string }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user