The DeepL free tier is metered (it failed in production with HTTP 456 Quota exceeded) and would require every deployment of this platform to carry its own API account. Translation now runs on M2M100 418M (MIT) via CTranslate2, shipped inside the pipeline image: no key, no quota, and no content or visitor data leaving the server. Also restores the multi-source behaviour of the original WordPress plugin, which the Python port had narrowed to Spanish-only. Any of the three site languages can now be the authored original. Because any language can be a source, loop prevention is no longer structural and is now explicit: generated siblings carry `translated_from` and are never treated as sources, and the bot's own [skip-translate] commits are skipped outright (that marker was already being written but never read). Markup protection moves in-process now that DeepL's tag_handling=html is gone. Code blocks and raw HTML pass through untouched; link targets, inline code and protected community terms are masked with placeholders that are verified to survive the round trip, failing the pipeline rather than shipping corrupted text. Two fixes along the way: - Generated siblings no longer inherit the source's `slug`. They did, which meant the first retranslation of a WordPress-migrated post moved /de/<german-slug>/ onto /de/<spanish-slug>/ and destroyed the inbound link preservation wp-to-hugo.py exists for. - `manual_translation` now works from the CMS. Decap only ever exposed it on the source while the script read it on the target, so the toggle did nothing. It now means "hands off" on both sides. wp-to-hugo.py marks migrated Polylang siblings frozen, since those are human translations and regenerating them would replace them with weaker machine output. Adds --backfill for sources missing siblings, which also fixes the existing 404s on /de/page/acerca/ and /pt-br/page/contacto/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
245 lines
8.8 KiB
Python
245 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Self-hosted translation step for Woodpecker CI.
|
|
|
|
Replaces the DeepL API call this script used to make, and restores the
|
|
multi-source behaviour of the original WordPress plugin: any of the three site
|
|
languages may be the authored original, and the other two are generated from
|
|
it. Translation runs on a model shipped inside the pipeline image, so there is
|
|
no API key, no quota and no third-party request.
|
|
|
|
Loop prevention, which was structural back when only Spanish could be a source:
|
|
* a generated sibling carries `translated_from`, and a file carrying it is
|
|
never itself treated as a source;
|
|
* the bot's own commits carry [skip-translate] and are skipped outright.
|
|
|
|
`manual_translation: true` means "hands off", on both sides:
|
|
* on an authored source — do not generate siblings for this post at all;
|
|
* on a generated sibling — never overwrite it again.
|
|
|
|
Failure behaviour: any translation error exits non-zero, the pipeline goes red,
|
|
and nothing partial is committed — no half-translated sets.
|
|
|
|
Environment:
|
|
MT_MODEL_DIR / MT_TOKENIZER / MT_COMPUTE_TYPE / MT_THREADS see translation/
|
|
CI_COMMIT_SHA / CI_PREV_COMMIT_SHA / CI_COMMIT_MESSAGE from Woodpecker
|
|
GITEA_PUSH_TOKEN bot push token
|
|
|
|
Dependencies: ctranslate2, transformers, sentencepiece, sentencex, pyyaml
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from translation.ctranslate_provider import CTranslate2Provider
|
|
from translation.markdown import translate_markdown, translate_text
|
|
from translation.provider import SITE_LANGS
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
CONTENT_DIR = REPO_ROOT / "content"
|
|
|
|
# Frontmatter strings translated alongside the body. Note `categories` is
|
|
# deliberately absent: the taxonomy terms stay Spanish in every language, or
|
|
# Hugo would fork the taxonomy per language.
|
|
TRANSLATED_KEYS = ("title", "description")
|
|
|
|
BOT_NAME = "vienalatina-translations"
|
|
BOT_EMAIL = "translations@vienalatina.com"
|
|
SKIP_MARKER = "[skip-translate]"
|
|
|
|
|
|
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 split_lang(path: Path) -> tuple[str, str] | None:
|
|
"""('mi-articulo', 'es') for mi-articulo.es.md, else None."""
|
|
if path.suffix != ".md":
|
|
return None
|
|
stem = path.name[: -len(".md")]
|
|
for lang in SITE_LANGS:
|
|
if stem.endswith(f".{lang}"):
|
|
return stem[: -(len(lang) + 1)], lang
|
|
return None
|
|
|
|
|
|
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 read_frontmatter(path: Path) -> dict:
|
|
if not path.exists():
|
|
return {}
|
|
fm, _ = split_frontmatter(path.read_text(encoding="utf-8"))
|
|
return fm
|
|
|
|
|
|
def is_generated(fm: dict) -> bool:
|
|
return bool(fm.get("translated_from"))
|
|
|
|
|
|
def is_frozen(fm: dict) -> bool:
|
|
return bool(fm.get("manual_translation"))
|
|
|
|
|
|
def changed_markdown() -> list[Path]:
|
|
"""Content files touched by the pushed commits."""
|
|
head = os.environ.get("CI_COMMIT_SHA", "HEAD")
|
|
prev = os.environ.get("CI_PREV_COMMIT_SHA", "")
|
|
if prev and not set(prev) <= {"0"}:
|
|
out = run("git", "diff", "--name-only", "--diff-filter=AM", prev, head)
|
|
elif run("git", "rev-list", "--count", "HEAD") != "1":
|
|
out = run("git", "diff", "--name-only", "--diff-filter=AM", "HEAD~1", "HEAD")
|
|
else: # first commit in the repo
|
|
out = run("git", "ls-files")
|
|
|
|
paths = []
|
|
for line in out.splitlines():
|
|
rel = Path(line.strip())
|
|
if rel.parts[:1] == ("content",) and (REPO_ROOT / rel).exists():
|
|
paths.append(REPO_ROOT / rel)
|
|
return paths
|
|
|
|
|
|
def authored_sources(paths: list[Path]) -> list[tuple[Path, str, str]]:
|
|
"""(path, basename, lang) for files that may act as a translation source."""
|
|
sources = []
|
|
for path in paths:
|
|
parsed = split_lang(path)
|
|
if not parsed:
|
|
continue
|
|
basename, lang = parsed
|
|
fm = read_frontmatter(path)
|
|
if is_generated(fm):
|
|
continue # machine output is never a source
|
|
if is_frozen(fm):
|
|
print(f"{path.relative_to(REPO_ROOT)}: manual_translation=true — not translated")
|
|
continue
|
|
sources.append((path, basename, lang))
|
|
return sources
|
|
|
|
|
|
def missing_siblings() -> list[Path]:
|
|
"""Every authored source missing at least one sibling."""
|
|
incomplete = []
|
|
for path in sorted(CONTENT_DIR.rglob("*.md")):
|
|
parsed = split_lang(path)
|
|
if not parsed:
|
|
continue
|
|
basename, lang = parsed
|
|
if is_generated(read_frontmatter(path)):
|
|
continue
|
|
for target in SITE_LANGS:
|
|
if target != lang and not path.with_name(f"{basename}.{target}.md").exists():
|
|
incomplete.append(path)
|
|
break
|
|
return incomplete
|
|
|
|
|
|
def translate_file(source: Path, basename: str, src_lang: str, provider) -> list[Path]:
|
|
fm, body = split_frontmatter(source.read_text(encoding="utf-8"))
|
|
written: list[Path] = []
|
|
|
|
for tgt in SITE_LANGS:
|
|
if tgt == src_lang:
|
|
continue
|
|
target = source.with_name(f"{basename}.{tgt}.md")
|
|
target_fm = read_frontmatter(target)
|
|
if is_frozen(target_fm):
|
|
print(f" {target.relative_to(REPO_ROOT)}: manual_translation=true — skipped")
|
|
continue
|
|
|
|
new_fm = dict(fm)
|
|
for key in TRANSLATED_KEYS:
|
|
if fm.get(key):
|
|
new_fm[key] = translate_text(str(fm[key]), src_lang, tgt, provider)
|
|
new_fm["lang"] = tgt
|
|
new_fm["translated_from"] = src_lang
|
|
new_fm["manual_translation"] = False
|
|
|
|
# Never inherit the source's slug: that would move a migrated sibling's
|
|
# URL onto the source's and break inbound links. Keep the slug the
|
|
# target already had; otherwise let Hugo fall back to the filename.
|
|
new_fm.pop("slug", None)
|
|
if target_fm.get("slug"):
|
|
new_fm["slug"] = target_fm["slug"]
|
|
|
|
target.write_text(
|
|
join_frontmatter(new_fm, translate_markdown(body, src_lang, tgt, provider)),
|
|
encoding="utf-8",
|
|
)
|
|
written.append(target)
|
|
print(f" {target.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: # clone credentials are read-only; pushing needs the bot 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", f"translate: update generated siblings {SKIP_MARKER}")
|
|
run("git", "push", "origin", f"HEAD:{branch}")
|
|
print(f"Pushed sibling commit to {branch}.")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Generate translated content siblings.")
|
|
parser.add_argument("--backfill", action="store_true",
|
|
help="translate every source missing siblings, not just changed files")
|
|
parser.add_argument("--no-push", action="store_true",
|
|
help="write siblings but do not commit or push (local testing)")
|
|
args = parser.parse_args()
|
|
|
|
if SKIP_MARKER in os.environ.get("CI_COMMIT_MESSAGE", ""):
|
|
print(f"{SKIP_MARKER} commit — nothing to translate.")
|
|
return
|
|
|
|
paths = missing_siblings() if args.backfill else changed_markdown()
|
|
sources = authored_sources(paths)
|
|
if not sources:
|
|
print("No authored content changed — nothing to translate.")
|
|
return
|
|
|
|
provider = CTranslate2Provider()
|
|
written: list[Path] = []
|
|
for source, basename, lang in sources:
|
|
print(f"Translating {source.relative_to(REPO_ROOT)} (from {lang}):")
|
|
written.extend(translate_file(source, basename, lang, provider))
|
|
|
|
if not written:
|
|
print("Every sibling is frozen — nothing written.")
|
|
return
|
|
if args.no_push:
|
|
print(f"--no-push: wrote {len(written)} files, leaving them uncommitted.")
|
|
return
|
|
commit_and_push(written)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|