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