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
This commit is contained in:
Claude 2026-07-31 11:17:32 +00:00
commit 72894b8907
No known key found for this signature in database
26 changed files with 4766 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# Hugo build output
public/
resources/
.hugo_build.lock
# Decap bundle is downloaded at build time (no third-party CDN at runtime)
static/admin/decap-cms.js
__pycache__/

45
.woodpecker.yml Normal file
View File

@ -0,0 +1,45 @@
# Woodpecker CI: translate → build → deploy
#
# Triggered by the Gitea push webhook. The translate step replaces the old
# synchronous WordPress save_post hook: publishing in Decap is one git
# commit, translation happens here, asynchronously, and every DeepL output
# lands as a reviewable bot commit.
#
# Secrets to configure in Woodpecker (repo settings → secrets):
# deepl_api_key — DeepL free-tier key (500k chars/mo)
# gitea_push_token — Gitea token for the translations bot user
# (translations@vienalatina.com) with repo write access
#
# The deploy step writes to the Caddy web root via a host mount, which
# requires the repo to be marked "trusted" in Woodpecker.
when:
- event: push
branch: main
steps:
translate:
image: python:3.12-slim
environment:
DEEPL_API_KEY:
from_secret: deepl_api_key
GITEA_PUSH_TOKEN:
from_secret: gitea_push_token
commands:
- pip install --quiet requests pyyaml
- python scripts/translate.py
build:
image: hugomods/hugo:0.135.0
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
deploy:
image: alpine:3.20
volumes:
- /var/www/vienalatina.com:/deploy-target
commands:
- apk add --no-cache rsync
- rsync -a --delete public/ /deploy-target/

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# 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.
```
Pablo ──► Decap CMS (/admin) ──commit──► Gitea ──webhook──► Woodpecker CI
│
translate (DeepL, async) ─┤
build (hugo) ─┤
deploy (rsync) ─┘
▼
Caddy 2 serves /var/www/vienalatina.com
```
## Languages
Spanish is the authoring language; German (`/de/`) and Brazilian Portuguese
(`/pt-br/`) siblings are generated by CI as reviewable git commits. Siblings
pair by filename basename:
```
content/post/mi-articulo.es.md ← authored in Decap
content/post/mi-articulo.de.md ← written by scripts/translate.py
content/post/mi-articulo.pt-br.md ← written by scripts/translate.py
```
Set `manual_translation: true` in a sibling's frontmatter to freeze it —
CI will never overwrite it again.
### Frontmatter contract
```yaml
---
title: "Mi artículo"
date: 2026-07-31
lang: es
manual_translation: false # set true on *.de.md/*.pt-br.md to freeze
categories: [Gastronomía]
---
```
## Translation flow
1. Publish `*.es.md` in Decap → one git commit, no waiting on DeepL.
2. Gitea webhook fires Woodpecker.
3. `scripts/translate.py` diffs the push, translates changed `*.es.md` via
DeepL (`tag_handling=html`, protected community terms), writes the
siblings, and pushes them back as a bot commit
(`translations@vienalatina.com`).
4. `hugo --minify` builds, `rsync --delete` deploys, Caddy serves.
Any DeepL error fails the pipeline visibly (red X, one-click retry) —
no half-translated sets ever ship.
## Local development
```sh
hugo server # http://localhost:1313
```
## CI secrets (Woodpecker → repo settings → secrets)
| Secret | Value |
|---|---|
| `deepl_api_key` | DeepL free-tier key (500k chars/mo) |
| `gitea_push_token` | Gitea token for the translations bot user, repo write access |
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
```sh
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/...`).

100
config.yaml Normal file
View File

@ -0,0 +1,100 @@
# Viena Latina — Hugo site configuration
# Three languages: Spanish (default, at /), German (/de/), Brazilian Portuguese (/pt-br/).
# Siblings pair by filename basename: mi-articulo.es.md ↔ mi-articulo.de.md ↔ mi-articulo.pt-br.md
baseURL: "https://vienalatina.com/"
title: "Viena Latina"
theme: "vienalatina"
defaultContentLanguage: "es"
defaultContentLanguageInSubdir: false
# Preserve WordPress slugs so external inbound links keep working.
permalinks:
post: "/:slug/"
taxonomies:
category: "categories"
tag: "tags"
languages:
es:
languageCode: "es"
languageName: "Español"
weight: 1
params:
description: "Comunidad latinoamericana en Viena — turismo, cultura, gastronomía, comunidad y comercio."
menus:
main:
- name: "Inicio"
url: "/"
weight: 1
- name: "Acerca"
url: "/page/acerca/"
weight: 2
- name: "Contacto"
url: "/page/contacto/"
weight: 3
de:
languageCode: "de"
languageName: "Deutsch"
weight: 2
params:
description: "Lateinamerikanische Community in Wien — Tourismus, Kultur, Gastronomie, Gemeinschaft und Handel."
menus:
main:
- name: "Start"
url: "/de/"
weight: 1
- name: "Über uns"
url: "/de/page/acerca/"
weight: 2
- name: "Kontakt"
url: "/de/page/contacto/"
weight: 3
pt-br:
languageCode: "pt-BR"
languageName: "Português (Brasil)"
weight: 3
params:
description: "Comunidade latino-americana em Viena — turismo, cultura, gastronomia, comunidade e comércio."
menus:
main:
- name: "Início"
url: "/pt-br/"
weight: 1
- name: "Sobre"
url: "/pt-br/page/acerca/"
weight: 2
- name: "Contato"
url: "/pt-br/page/contacto/"
weight: 3
params:
# og:locale values per language slug (ported from theme/inc/seo.php).
localeMap:
es: "es_ES"
de: "de_DE"
pt-br: "pt_BR"
# llms.txt — custom plain-text output rendered from layouts/index.llms.txt
# (ported from theme/inc/robots-and-llms.php).
outputFormats:
llms:
mediaType: "text/plain"
baseName: "llms"
isPlainText: true
outputs:
home: ["HTML", "RSS", "llms"]
sitemap:
changefreq: "weekly"
priority: 0.5
markup:
goldmark:
renderer:
unsafe: true # WP-migrated content contains inline HTML
enableRobotsTXT: false # robots.txt is a static file in static/

View File

@ -0,0 +1,9 @@
---
title: "Acerca"
lang: es
manual_translation: false
---
**Viena Latina** es la comunidad latinoamericana en Viena: guías de turismo,
agenda cultural, gastronomía, vida comunitaria y comercios latinos en la
ciudad.

View File

@ -0,0 +1,7 @@
---
title: "Contacto"
lang: es
manual_translation: false
---
Escríbenos a **hola@vienalatina.com**.

View File

@ -0,0 +1,18 @@
---
title: "Willkommen bei Viena Latina"
date: 2026-07-31
slug: bienvenida-a-viena-latina
lang: de
manual_translation: false
categories: [Comunidad]
description: "Die neue Website von Viena Latina: schneller, selbst gehostet und mit überprüfbaren Übersetzungen auf Deutsch und Portugiesisch."
---
Das ist das neue Zuhause von **Viena Latina**. Die Website ist jetzt komplett
statisch: Jeder Artikel wird auf Spanisch veröffentlicht und automatisch ins
Deutsche und brasilianische Portugiesisch übersetzt, mit eigenen URLs pro
Sprache.
Für unsere Leserinnen und Leser ändert sich nichts — dieselben Rubriken wie
immer: Tourismus, Kultur, Gastronomie, Gemeinschaft und Handel. Alles lädt
schneller, ohne Cookies und ohne Tracker.

View File

@ -0,0 +1,16 @@
---
title: "Bienvenida a Viena Latina"
date: 2026-07-31
lang: es
manual_translation: false
categories: [Comunidad]
description: "El nuevo sitio de Viena Latina: más rápido, autoalojado y con traducciones revisables en alemán y portugués."
---
Este es el nuevo hogar de **Viena Latina**. El sitio ahora es completamente
estático: cada artículo se publica en español y se traduce automáticamente al
alemán y al portugués brasileño, con URLs propias para cada idioma.
Nada cambia para quienes nos leen — las mismas secciones de siempre: Turismo,
Cultura, Gastronomía, Comunidad y Comercio. Todo carga más rápido, sin cookies
y sin rastreadores.

View File

@ -0,0 +1,17 @@
---
title: "Bem-vindos ao Viena Latina"
date: 2026-07-31
slug: bienvenida-a-viena-latina
lang: pt-br
manual_translation: false
categories: [Comunidad]
description: "O novo site do Viena Latina: mais rápido, auto-hospedado e com traduções revisáveis em alemão e português."
---
Este é o novo lar do **Viena Latina**. O site agora é totalmente estático:
cada artigo é publicado em espanhol e traduzido automaticamente para o alemão
e o português brasileiro, com URLs próprias para cada idioma.
Nada muda para quem nos lê — as mesmas seções de sempre: Turismo, Cultura,
Gastronomia, Comunidade e Comércio. Tudo carrega mais rápido, sem cookies e
sem rastreadores.

235
scripts/translate.py Normal file
View File

@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Async DeepL translation step for Woodpecker CI.
Replaces the synchronous `save_post` hook from the WordPress plugin
(plataforma_deepl_translate in plugin/plataforma-social/plataforma-social.php).
For each *.es.md changed in the pushed commit range, calls DeepL with
tag_handling=html (markup survives translation) plus a glossary of
community-specific terms, and writes the *.de.md and *.pt-br.md siblings
next to the source. Sibling files carrying `manual_translation: true` in
their frontmatter are never overwritten. The siblings are committed back
to the same branch as a bot commit, then the pipeline builds and deploys.
Failure behaviour: any DeepL error exits non-zero, the pipeline goes red,
and nothing partial is committed — no half-translated sets.
Environment:
DEEPL_API_KEY required (Woodpecker secret)
DEEPL_API_URL optional, defaults to the free-tier endpoint
CI_COMMIT_SHA / CI_PREV_COMMIT_SHA provided by Woodpecker
Dependencies: requests, pyyaml
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
import requests
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
DEEPL_URL = os.environ.get("DEEPL_API_URL", "https://api-free.deepl.com/v2/translate")
# Language slug -> DeepL target code (ported from the plugin's $lang_map).
TARGETS = {
"de": "DE",
"pt-br": "PT-BR",
}
SOURCE_SLUG = "es"
DEEPL_SOURCE = "ES"
# Fixed community-specific terms DeepL must not "translate".
# Sent as ignored tags via tag_handling=html: each term is wrapped in
# <keep>…</keep> before the call and unwrapped after, which pins proper
# nouns without needing a server-side DeepL glossary resource.
PROTECTED_TERMS = [
"Viena Latina",
"Grätzl",
"empanadas de viento",
"Naschmarkt",
]
# Frontmatter keys whose string values get translated alongside the body.
TRANSLATED_KEYS = ("title", "description")
BOT_NAME = "vienalatina-translations"
BOT_EMAIL = "translations@vienalatina.com"
def run(*args: str, check: bool = True) -> str:
result = subprocess.run(args, cwd=REPO_ROOT, check=check, capture_output=True, text=True)
return result.stdout.strip()
def changed_source_files() -> list[Path]:
"""Spanish sources touched by the pushed commits.
Filtering to *.es.md is what makes bot commits (which only add .de.md /
.pt-br.md) a no-op round — the re-fire loop the WP hook had to guard
against with meta flags cannot happen here.
"""
head = os.environ.get("CI_COMMIT_SHA", "HEAD")
prev = os.environ.get("CI_PREV_COMMIT_SHA", "")
if prev and not set(prev) <= {"0"}:
diff_range = [prev, head]
else:
diff_range = ["HEAD~1", "HEAD"] if run("git", "rev-list", "--count", "HEAD") != "1" else None
if diff_range:
out = run("git", "diff", "--name-only", "--diff-filter=AM", *diff_range)
else: # very first commit in the repo: translate everything
out = run("git", "ls-files")
files = []
for line in out.splitlines():
p = Path(line.strip())
if p.suffix == ".md" and p.name.endswith(f".{SOURCE_SLUG}.md") and p.parts[:1] == ("content",):
full = REPO_ROOT / p
if full.exists():
files.append(full)
return files
def split_frontmatter(text: str) -> tuple[dict, str]:
match = re.match(r"\A---\n(.*?)\n---\n?(.*)\Z", text, re.DOTALL)
if not match:
return {}, text
return yaml.safe_load(match.group(1)) or {}, match.group(2)
def join_frontmatter(fm: dict, body: str) -> str:
front = yaml.safe_dump(fm, allow_unicode=True, sort_keys=False, default_flow_style=False)
return f"---\n{front}---\n\n{body.lstrip()}"
def protect(text: str) -> str:
for term in PROTECTED_TERMS:
text = re.sub(re.escape(term), lambda m: f"<keep>{m.group(0)}</keep>", text, flags=re.IGNORECASE)
return text
def unprotect(text: str) -> str:
return re.sub(r"</?keep>", "", text)
def deepl_translate(texts: list[str], target: str, html: bool) -> list[str]:
"""Direct port of plataforma_deepl_translate(): same endpoint, same
tag_handling=html behaviour, but errors abort the pipeline instead of
silently shipping a half-translated post."""
texts = [t for t in texts if isinstance(t, str) and t.strip()]
if not texts:
return []
key = os.environ.get("DEEPL_API_KEY", "")
if not key:
sys.exit("DEEPL_API_KEY is not set — configure the Woodpecker secret.")
data: list[tuple[str, str]] = [
("target_lang", target),
("source_lang", DEEPL_SOURCE),
("tag_handling", "html"),
("ignore_tags", "keep"),
]
if not html:
# Titles/descriptions are plain strings; still use tag handling so
# <keep> protection works, DeepL just has no other tags to preserve.
pass
for t in texts:
data.append(("text", protect(t)))
resp = requests.post(
DEEPL_URL,
headers={"Authorization": f"DeepL-Auth-Key {key}"},
data=data,
timeout=60,
)
if resp.status_code != 200:
sys.exit(f"DeepL HTTP {resp.status_code}: {resp.text[:300]}")
return [unprotect(item["text"]) for item in resp.json().get("translations", [])]
def sibling_path(source: Path, slug: str) -> Path:
return source.with_name(source.name.replace(f".{SOURCE_SLUG}.md", f".{slug}.md"))
def is_frozen(path: Path) -> bool:
if not path.exists():
return False
fm, _ = split_frontmatter(path.read_text(encoding="utf-8"))
return bool(fm.get("manual_translation"))
def translate_file(source: Path) -> list[Path]:
raw = source.read_text(encoding="utf-8")
fm, body = split_frontmatter(raw)
written = []
for slug, deepl_target in TARGETS.items():
target_file = sibling_path(source, slug)
if is_frozen(target_file):
print(f" {target_file.relative_to(REPO_ROOT)}: manual_translation=true — skipped")
continue
strings = [str(fm[k]) for k in TRANSLATED_KEYS if fm.get(k)]
translated_strings = deepl_translate(strings, deepl_target, html=False)
translated_body = deepl_translate([body], deepl_target, html=True) if body.strip() else [""]
new_fm = dict(fm)
it = iter(translated_strings)
for k in TRANSLATED_KEYS:
if fm.get(k):
new_fm[k] = next(it)
new_fm["lang"] = slug
new_fm["manual_translation"] = False
# Pin the URL to the shared basename so it never drifts when a title
# is retranslated (plan: /de/mi-articulo/ pairs with /mi-articulo/).
new_fm.setdefault("slug", source.name.removesuffix(f".{SOURCE_SLUG}.md"))
target_file.write_text(join_frontmatter(new_fm, translated_body[0] if translated_body else ""), encoding="utf-8")
written.append(target_file)
print(f" {target_file.relative_to(REPO_ROOT)}: written")
return written
def commit_and_push(files: list[Path]) -> None:
branch = os.environ.get("CI_COMMIT_BRANCH", "main")
run("git", "config", "user.name", BOT_NAME)
run("git", "config", "user.email", BOT_EMAIL)
token = os.environ.get("GITEA_PUSH_TOKEN", "")
repo = os.environ.get("CI_REPO", "pablo/vienalatina")
if token: # Woodpecker's clone credentials are read-only; push needs its own token
run("git", "remote", "set-url", "origin", f"https://{BOT_NAME}:{token}@git.vienalatina.com/{repo}.git")
run("git", "add", *[str(f) for f in files])
if not run("git", "status", "--porcelain"):
print("Translations identical to committed siblings — nothing to push.")
return
run("git", "commit", "-m", "translate: update DE and PT-BR siblings [skip-translate]")
run("git", "push", "origin", f"HEAD:{branch}")
print(f"Pushed sibling commit to {branch}.")
def main() -> None:
sources = changed_source_files()
if not sources:
print("No changed *.es.md files — nothing to translate.")
return
all_written: list[Path] = []
for source in sources:
print(f"Translating {source.relative_to(REPO_ROOT)}:")
all_written.extend(translate_file(source))
if all_written:
commit_and_push(all_written)
if __name__ == "__main__":
main()

142
scripts/wp-to-hugo.py Normal file
View File

@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""One-shot WordPress → Hugo content migration (disposable).
Pulls every post from the WP REST API (with Polylang language codes),
converts HTML bodies to Markdown, downloads images into static/uploads/,
rewrites their URLs, and writes content/post/<slug>.<lang>.md files whose
basenames pair Polylang siblings the way Hugo expects.
Usage:
pip install requests html2text
python scripts/wp-to-hugo.py https://vielac.at
Existing Spanish slugs are preserved so inbound links keep resolving after
the DNS cutover (add Caddy 301s for anything that doesn't map cleanly).
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from urllib.parse import urlparse
import html2text
import requests
REPO_ROOT = Path(__file__).resolve().parent.parent
POSTS_DIR = REPO_ROOT / "content" / "post"
UPLOADS_DIR = REPO_ROOT / "static" / "uploads"
# Polylang slug -> Hugo language slug used in filenames.
LANG_MAP = {"es": "es", "de": "de", "pt-br": "pt-br", "pt": "pt-br"}
def fetch_all(base_url: str, endpoint: str) -> list[dict]:
items, page = [], 1
while True:
resp = requests.get(
f"{base_url}/wp-json/wp/v2/{endpoint}",
params={"per_page": 100, "page": page, "_embed": "1", "lang": ""},
timeout=30,
)
if resp.status_code == 400: # past the last page
break
resp.raise_for_status()
batch = resp.json()
if not batch:
break
items.extend(batch)
if page >= int(resp.headers.get("X-WP-TotalPages", 1)):
break
page += 1
return items
def download_image(url: str) -> str:
"""Fetch a WP upload into static/uploads/, return its new site-relative URL."""
name = Path(urlparse(url).path).name
if not name:
return url
dest = UPLOADS_DIR / name
if not dest.exists():
resp = requests.get(url, timeout=30)
if resp.status_code != 200:
print(f" ! image fetch failed ({resp.status_code}): {url}")
return url
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
dest.write_bytes(resp.content)
return f"/uploads/{name}"
def rewrite_images(html: str, base_url: str) -> str:
pattern = re.escape(base_url.rstrip("/")) + r"/wp-content/uploads/[^\s\"'<>)]+"
return re.sub(pattern, lambda m: download_image(m.group(0)), html)
def to_markdown(html: str) -> str:
conv = html2text.HTML2Text()
conv.body_width = 0
conv.protect_links = True
return conv.handle(html).strip()
def frontmatter(post: dict, lang: str, categories: dict[int, str]) -> str:
title = to_markdown(post["title"]["rendered"]).replace('"', '\\"')
cats = [categories[c] for c in post.get("categories", []) if c in categories]
lines = [
"---",
f'title: "{title}"',
f"date: {post['date']}",
f"slug: {post['slug']}", # preserve the exact WP slug per language
f"lang: {lang}",
"manual_translation: false",
f"categories: [{', '.join(cats)}]",
]
excerpt = to_markdown(post.get("excerpt", {}).get("rendered", ""))
if excerpt:
lines.append(f'description: "{excerpt[:300].replace(chr(34), chr(39))}"')
embedded = post.get("_embedded", {}).get("wp:featuredmedia", [])
if embedded and embedded[0].get("source_url"):
lines.append(f"image: {download_image(embedded[0]['source_url'])}")
lines.append("---")
return "\n".join(lines)
def main() -> None:
if len(sys.argv) != 2:
sys.exit("Usage: python scripts/wp-to-hugo.py https://your-wp-site.example")
base_url = sys.argv[1].rstrip("/")
categories = {c["id"]: c["name"] for c in fetch_all(base_url, "categories")}
posts = fetch_all(base_url, "posts")
print(f"Fetched {len(posts)} posts, {len(categories)} categories.")
by_id = {p["id"]: p for p in posts}
POSTS_DIR.mkdir(parents=True, exist_ok=True)
written = 0
for post in posts:
lang = LANG_MAP.get(post.get("lang", "es"))
if not lang:
continue
# Polylang exposes sibling IDs in `translations`; use the Spanish
# sibling's slug as the shared basename so Hugo pairs the set.
translations = post.get("translations", {})
es_id = translations.get("es", post["id"])
basename = by_id.get(es_id, post)["slug"]
body_html = rewrite_images(post["content"]["rendered"], base_url)
md = frontmatter(post, lang, categories) + "\n\n" + to_markdown(body_html) + "\n"
out = POSTS_DIR / f"{basename}.{lang}.md"
out.write_text(md, encoding="utf-8")
written += 1
print(f" {out.relative_to(REPO_ROOT)}")
print(f"Wrote {written} files. Spot-check ~10 before committing (formatting fidelity).")
if __name__ == "__main__":
main()

52
static/admin/config.yml Normal file
View File

@ -0,0 +1,52 @@
# Decap CMS configuration — Gitea backend (OAuth app registered in Gitea admin).
backend:
name: gitea
repo: pablo/vienalatina # owner/repo inside Gitea
branch: main
base_url: https://git.vienalatina.com
api_root: https://git.vienalatina.com/api/v1
app_id: REPLACE_WITH_GITEA_OAUTH_CLIENT_ID
site_url: https://vienalatina.com
display_url: https://vienalatina.com
media_folder: static/uploads
public_folder: /uploads
# Pablo authors Spanish sources only; CI generates the .de.md / .pt-br.md siblings.
collections:
- name: post
label: "Artículos"
label_singular: "Artículo"
folder: content/post
create: true
extension: md
format: yaml-frontmatter
slug: "{{slug}}.es"
filter: { field: lang, value: es }
fields:
- { name: title, label: "Título", widget: string }
- { name: date, label: "Fecha", widget: datetime }
- { name: lang, label: "Idioma", widget: hidden, default: es }
- { name: manual_translation, label: "Traducción manual (congelar)", widget: boolean, default: false, required: false }
- { name: categories, label: "Categorías", widget: select, multiple: true,
options: ["Turismo", "Cultura", "Gastronomía", "Comunidad", "Comercio"] }
- { name: description, label: "Resumen", widget: text, required: false }
- { name: image, label: "Imagen destacada", widget: image, required: false }
- { name: body, label: "Cuerpo", widget: markdown }
- name: page
label: "Páginas"
label_singular: "Página"
folder: content/page
create: true
extension: md
format: yaml-frontmatter
slug: "{{slug}}.es"
filter: { field: lang, value: es }
fields:
- { name: title, label: "Título", widget: string }
- { name: date, label: "Fecha", widget: datetime, required: false }
- { name: lang, label: "Idioma", widget: hidden, default: es }
- { name: manual_translation, label: "Traducción manual (congelar)", widget: boolean, default: false, required: false }
- { name: body, label: "Cuerpo", widget: markdown }

14
static/admin/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Viena Latina — Admin</title>
</head>
<body>
<!-- decap-cms.js is bundled locally: the CI build step downloads it once into
static/admin/ (gitignored) so no third-party CDN is hit at runtime (GDPR). -->
<script src="/admin/decap-cms.js"></script>
</body>
</html>

39
static/robots.txt Normal file
View File

@ -0,0 +1,39 @@
# Viena Latina — robots.txt
# Ported from theme/inc/robots-and-llms.php (PR #37): explicit allow for AI crawlers.
User-agent: *
Allow: /
Disallow: /admin/
# AI crawlers — explicit allow
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: CCBot
Allow: /
User-agent: Amazonbot
Allow: /
User-agent: Bytespider
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: meta-externalagent
Allow: /
Sitemap: https://vienalatina.com/sitemap.xml

0
static/uploads/.gitkeep Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="{{ .Site.Language.LanguageCode }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ if not .IsHome }}{{ .Title }} — {{ end }}{{ .Site.Title }}</title>
{{ partial "seo-head.html" . }}
{{ $css := resources.Get "css/main.css" | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
<link rel="canonical" href="{{ .Permalink }}">
{{ range .AlternativeOutputFormats -}}
<link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .Permalink | safeURL }}">
{{ end -}}
</head>
<body>
{{ partial "header.html" . }}
<div class="page-shell">
{{ block "main" . }}{{ end }}
{{ partial "footer.html" . }}
</div>
</body>
</html>

View File

@ -0,0 +1,19 @@
{{/* Category / archive listing — article-card markup from the WP theme feed. */}}
{{ define "main" }}
<main class="feed">
<h1 class="feed__title">{{ .Title }}</h1>
{{ range .Pages }}
<article class="article-card">
<div class="article-card__meta">
{{ range first 1 (.GetTerms "categories") }}
<a class="article-card__kind" href="{{ .Permalink }}">{{ .LinkTitle }}</a>
{{ end }}
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 Jan 2006" }}</time>
</div>
<h2 class="article-card__title"><a href="{{ .Permalink }}">{{ .Title }}</a></h2>
<p class="article-card__excerpt">{{ .Description | default (.Summary | plainify | truncate 200) }}</p>
</article>
{{ end }}
</main>
{{ end }}

View File

@ -0,0 +1,40 @@
{{/* Article template — ported from theme/single.php (minus likes/comments/login,
which have no place on a static site). */}}
{{ define "main" }}
<main class="feed feed--single">
<article class="article-single">
<header class="article-single__head">
<div>
<div class="article-card__meta">
{{ range first 1 (.GetTerms "categories") }}
<a class="article-card__kind" href="{{ .Permalink }}">{{ .LinkTitle }}</a>
{{ end }}
<time datetime="{{ .Date.Format "2006-01-02T15:04:05-07:00" }}">
{{ .Date.Format "2 Jan 2006" }}
</time>
</div>
</div>
</header>
<h1 class="article-single__title">{{ .Title }}</h1>
{{ with .Description }}
<p class="article-single__summary">{{ . }}</p>
{{ end }}
{{ with .Params.image }}
<img class="article-single__cover" src="{{ . | relURL }}" alt="">
{{ end }}
<div class="article-single__body">
{{ .Content }}
</div>
<a class="back-link" href="{{ .Site.Home.Permalink }}">
← Volver al inicio
</a>
</article>
</main>
{{ end }}

View File

@ -0,0 +1,17 @@
{{/* Home: feed of recent articles in the current language. */}}
{{ define "main" }}
<main class="feed">
{{ range first 20 (where .Site.RegularPages "Section" "post") }}
<article class="article-card">
<div class="article-card__meta">
{{ range first 1 (.GetTerms "categories") }}
<a class="article-card__kind" href="{{ .Permalink }}">{{ .LinkTitle }}</a>
{{ end }}
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 Jan 2006" }}</time>
</div>
<h2 class="article-card__title"><a href="{{ .Permalink }}">{{ .Title }}</a></h2>
<p class="article-card__excerpt">{{ .Description | default (.Summary | plainify | truncate 200) }}</p>
</article>
{{ end }}
</main>
{{ end }}

View File

@ -0,0 +1,13 @@
# {{ .Site.Title }}
> {{ .Site.Params.description }}
Site home: {{ .Site.Home.Permalink }}
This site publishes in Spanish. Every article has server-rendered German (/de/) and Brazilian Portuguese (/pt-br/) translations, linked via hreflang.
## Recent articles
{{ range first 50 (where .Site.RegularPages "Section" "post") -}}
- [{{ .Title }}]({{ .Permalink }}): {{ .Description | default (.Summary | plainify | truncate 200) }}
{{ end -}}

View File

@ -0,0 +1,12 @@
{{/* Ported from theme/footer.php */}}
<footer class="site-foot">
{{ $pages := where .Site.RegularPages "Section" "page" }}
{{ if $pages }}
<nav class="site-foot__nav" aria-label="Páginas del sitio">
{{ range first 8 $pages }}
<a href="{{ .Permalink }}">{{ .Title }}</a>
{{ end }}
</nav>
{{ end }}
<p>{{ .Site.Title }} &copy; {{ now.Format "2006" }}</p>
</footer>

View File

@ -0,0 +1,25 @@
{{/* Ported from theme/header.php — static version: no login/account controls.
No Google Fonts: the font-family stack in main.css falls back to system
sans-serif, keeping the site free of third-party requests (GDPR). */}}
<header class="site-bar">
<div class="site-bar__inner">
<a class="site-bar__brand" href="{{ .Site.Home.Permalink }}" aria-label="Inicio">
<span class="site-bar__brand-text">{{ .Site.Title }}</span>
</a>
<nav class="site-bar__nav" aria-label="Idioma">
{{ partial "lang-switcher.html" . }}
</nav>
</div>
<nav class="site-bar__pages" id="site-bar-pages" aria-label="Secciones">
<ul class="page-nav">
{{ $current := . }}
{{ range .Site.Menus.main }}
<li{{ if $current.IsMenuCurrent "main" . }} class="current-menu-item"{{ end }}>
<a href="{{ .URL }}">{{ .Name }}</a>
</li>
{{ end }}
</ul>
</nav>
</header>

View File

@ -0,0 +1,17 @@
{{/* Ported from plataforma_render_lang_switcher() in theme/functions.php.
Three real <a href> links to sibling URLs — crawlable, no JS. */}}
{{ if .IsTranslated }}
{{ $labels := dict "es" "ES" "de" "DE" "pt-br" "PT" }}
<div class="lang-switcher" aria-label="Idioma / Language">
{{ $current := . }}
{{ range $i, $t := .AllTranslations }}
{{ if $i }}<span class="lang-sep" aria-hidden="true">·</span>{{ end }}
{{ $slug := $t.Language.Lang }}
{{ $active := eq $slug $current.Language.Lang }}
<a class="lang-btn" href="{{ $t.Permalink }}" hreflang="{{ $slug }}"
aria-current="{{ if $active }}true{{ else }}false{{ end }}"{{ if $active }} aria-pressed="true"{{ end }}>
{{- index $labels $slug | default (upper $slug) -}}
</a>
{{ end }}
</div>
{{ end }}

View File

@ -0,0 +1,85 @@
{{/* GEO/SEO surfaces — ported from theme/inc/seo.php:
hreflang alternates, Open Graph + og:locale(:alternate), Twitter Card,
JSON-LD BlogPosting (articles) / Blog (home), all with inLanguage. */}}
{{ $localeMap := .Site.Params.localeMap }}
{{ $locale := index $localeMap .Language.Lang | default "es_ES" }}
{{ $desc := "" }}
{{ if .IsPage }}{{ $desc = .Description | default (.Summary | plainify | truncate 200) }}{{ else }}{{ $desc = .Site.Params.description }}{{ end }}
<meta name="description" content="{{ $desc }}">
{{/* hreflang — one alternate per language sibling + x-default to Spanish */}}
{{ if .IsTranslated }}
{{ range .AllTranslations }}
<link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
{{ end }}
{{ range .AllTranslations }}{{ if eq .Language.Lang "es" }}
<link rel="alternate" hreflang="x-default" href="{{ .Permalink }}">
{{ end }}{{ end }}
{{ end }}
{{ if and .IsPage (eq .Section "post") }}
{{/* ---- Article ---- */}}
<meta property="og:type" content="article">
<meta property="og:site_name" content="{{ .Site.Title }}">
<meta property="og:title" content="{{ .Title }}">
<meta property="og:description" content="{{ $desc }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:locale" content="{{ $locale }}">
{{ range .Translations }}
{{ with index $localeMap .Language.Lang }}<meta property="og:locale:alternate" content="{{ . }}">{{ end }}
{{ end }}
{{ with .Params.image }}<meta property="og:image" content="{{ . | absURL }}">{{ end }}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ .Title }}">
<meta name="twitter:description" content="{{ $desc }}">
{{ $schema := dict
"@context" "https://schema.org"
"@type" "BlogPosting"
"inLanguage" .Site.Language.LanguageCode
"headline" .Title
"description" $desc
"url" .Permalink
"datePublished" (.Date.Format "2006-01-02T15:04:05-07:00")
"dateModified" (.Lastmod.Format "2006-01-02T15:04:05-07:00")
"author" (dict "@type" "Person" "name" (.Params.author | default .Site.Title))
"publisher" (dict "@type" "Organization" "name" .Site.Title "url" .Site.Home.Permalink)
}}
{{ with .Params.image }}{{ $schema = merge $schema (dict "image" (. | absURL)) }}{{ end }}
<script type="application/ld+json">{{ $schema | jsonify (dict "indent" " ") | safeJS }}</script>
{{ else if .IsHome }}
{{/* ---- Home ---- */}}
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{ .Site.Title }}">
<meta property="og:title" content="{{ .Site.Title }}">
<meta property="og:description" content="{{ $desc }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:locale" content="{{ $locale }}">
{{ $posts := first 10 (where .Site.RegularPages "Section" "post") }}
{{ $blogPost := slice }}
{{ range $posts }}
{{ $entry := dict
"@type" "BlogPosting"
"headline" .Title
"url" .Permalink
"datePublished" (.Date.Format "2006-01-02T15:04:05-07:00")
"author" (dict "@type" "Person" "name" (.Params.author | default .Site.Title))
}}
{{ with .Description }}{{ $entry = merge $entry (dict "description" .) }}{{ end }}
{{ $blogPost = $blogPost | append $entry }}
{{ end }}
{{ $schema := dict
"@context" "https://schema.org"
"@type" "Blog"
"name" .Site.Title
"description" $desc
"url" .Permalink
"inLanguage" .Site.Language.LanguageCode
}}
{{ if $blogPost }}{{ $schema = merge $schema (dict "blogPost" $blogPost) }}{{ end }}
<script type="application/ld+json">{{ $schema | jsonify (dict "indent" " ") | safeJS }}</script>
{{ end }}

View File

@ -0,0 +1,4 @@
name = "Viena Latina"
license = "MIT"
description = "Static port of the Plataforma WordPress theme for vienalatina.com — same CSS, three languages, zero third-party requests."
min_version = "0.120.0"