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:
commit
72894b8907
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal 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
45
.woodpecker.yml
Normal 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
101
README.md
Normal 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
100
config.yaml
Normal 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/
|
||||||
9
content/page/acerca.es.md
Normal file
9
content/page/acerca.es.md
Normal 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.
|
||||||
7
content/page/contacto.es.md
Normal file
7
content/page/contacto.es.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: "Contacto"
|
||||||
|
lang: es
|
||||||
|
manual_translation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
Escríbenos a **hola@vienalatina.com**.
|
||||||
18
content/post/bienvenida-a-viena-latina.de.md
Normal file
18
content/post/bienvenida-a-viena-latina.de.md
Normal 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.
|
||||||
16
content/post/bienvenida-a-viena-latina.es.md
Normal file
16
content/post/bienvenida-a-viena-latina.es.md
Normal 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.
|
||||||
17
content/post/bienvenida-a-viena-latina.pt-br.md
Normal file
17
content/post/bienvenida-a-viena-latina.pt-br.md
Normal 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
235
scripts/translate.py
Normal 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
142
scripts/wp-to-hugo.py
Normal 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
52
static/admin/config.yml
Normal 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
14
static/admin/index.html
Normal 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
39
static/robots.txt
Normal 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
0
static/uploads/.gitkeep
Normal file
3705
themes/vienalatina/assets/css/main.css
Normal file
3705
themes/vienalatina/assets/css/main.css
Normal file
File diff suppressed because it is too large
Load Diff
25
themes/vienalatina/layouts/_default/baseof.html
Normal file
25
themes/vienalatina/layouts/_default/baseof.html
Normal 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>
|
||||||
19
themes/vienalatina/layouts/_default/list.html
Normal file
19
themes/vienalatina/layouts/_default/list.html
Normal 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 }}
|
||||||
40
themes/vienalatina/layouts/_default/single.html
Normal file
40
themes/vienalatina/layouts/_default/single.html
Normal 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 }}
|
||||||
17
themes/vienalatina/layouts/index.html
Normal file
17
themes/vienalatina/layouts/index.html
Normal 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 }}
|
||||||
13
themes/vienalatina/layouts/index.llms.txt
Normal file
13
themes/vienalatina/layouts/index.llms.txt
Normal 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 -}}
|
||||||
12
themes/vienalatina/layouts/partials/footer.html
Normal file
12
themes/vienalatina/layouts/partials/footer.html
Normal 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 }} © {{ now.Format "2006" }}</p>
|
||||||
|
</footer>
|
||||||
25
themes/vienalatina/layouts/partials/header.html
Normal file
25
themes/vienalatina/layouts/partials/header.html
Normal 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>
|
||||||
17
themes/vienalatina/layouts/partials/lang-switcher.html
Normal file
17
themes/vienalatina/layouts/partials/lang-switcher.html
Normal 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 }}
|
||||||
85
themes/vienalatina/layouts/partials/seo-head.html
Normal file
85
themes/vienalatina/layouts/partials/seo-head.html
Normal 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 }}
|
||||||
4
themes/vienalatina/theme.toml
Normal file
4
themes/vienalatina/theme.toml
Normal 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"
|
||||||
Loading…
Reference in New Issue
Block a user