From 071eec52a1c3f4ed8cc3d2462776ff5528302cdd Mon Sep 17 00:00:00 2001 From: tym83 Date: Fri, 17 Jul 2026 10:41:05 +0500 Subject: [PATCH 01/20] feat(i18n): automated translation pipeline + Spanish & Portuguese In-tree localization engine for cozystack.io (hack/i18n/), building on the existing source_digest freshness convention (hack/check-i18n.sh): - worklist.py: diff detector (missing/stale pages via source_digest) - translate.py: Claude Opus translator with glossary, per-language style guides, protected code/shortcodes/URLs, SEO front-matter transcreation - ahrefs_keywords.py: per-locale SEO keyword maps (optional, degrades gracefully without AHREFS_API_KEY) - i18n-translate.yml: nightly + dispatch; PR then auto-merge (publish-then-review) - config.yaml: languages, model routing (all Opus), scope, blog cutoff (last ~2 months), publish mode Add Spanish (es) and Portuguese-BR (pt-br): hugo.yaml + production mounts, i18n/es.toml + i18n/pt-br.toml (key parity verified). hreflang alternates + x-default in head-end.html. CODEOWNERS exempts content// so the pipeline auto-merges translations while code stays gated. Co-Authored-By: Claude Signed-off-by: tym83 Signed-off-by: tym83 <6355522@gmail.com> --- .github/CODEOWNERS | 15 +++ .github/workflows/i18n-translate.yml | 87 ++++++++++++ config/production/hugo.yaml | 12 ++ hack/i18n/.gitignore | 2 + hack/i18n/README.md | 76 +++++++++++ hack/i18n/ahrefs_keywords.py | 120 +++++++++++++++++ hack/i18n/config.yaml | 93 +++++++++++++ hack/i18n/glossary.yaml | 114 ++++++++++++++++ hack/i18n/keyword-maps/de.yaml | 3 + hack/i18n/keyword-maps/es.yaml | 3 + hack/i18n/keyword-maps/hi.yaml | 3 + hack/i18n/keyword-maps/pt-br.yaml | 3 + hack/i18n/keyword-maps/ru.yaml | 3 + hack/i18n/keyword-maps/zh-cn.yaml | 3 + hack/i18n/lib.py | 189 +++++++++++++++++++++++++++ hack/i18n/prompts/translate.md | 46 +++++++ hack/i18n/style-guides/de.md | 5 + hack/i18n/style-guides/es.md | 5 + hack/i18n/style-guides/hi.md | 3 + hack/i18n/style-guides/pt-br.md | 5 + hack/i18n/style-guides/ru.md | 5 + hack/i18n/style-guides/zh-cn.md | 4 + hack/i18n/translate.py | 188 ++++++++++++++++++++++++++ hack/i18n/worklist.py | 60 +++++++++ hugo.yaml | 24 ++++ i18n/es.toml | 53 ++++++++ i18n/pt-br.toml | 53 ++++++++ layouts/partials/hooks/head-end.html | 13 ++ 28 files changed, 1190 insertions(+) create mode 100644 .github/workflows/i18n-translate.yml create mode 100644 hack/i18n/.gitignore create mode 100644 hack/i18n/README.md create mode 100644 hack/i18n/ahrefs_keywords.py create mode 100644 hack/i18n/config.yaml create mode 100644 hack/i18n/glossary.yaml create mode 100644 hack/i18n/keyword-maps/de.yaml create mode 100644 hack/i18n/keyword-maps/es.yaml create mode 100644 hack/i18n/keyword-maps/hi.yaml create mode 100644 hack/i18n/keyword-maps/pt-br.yaml create mode 100644 hack/i18n/keyword-maps/ru.yaml create mode 100644 hack/i18n/keyword-maps/zh-cn.yaml create mode 100644 hack/i18n/lib.py create mode 100644 hack/i18n/prompts/translate.md create mode 100644 hack/i18n/style-guides/de.md create mode 100644 hack/i18n/style-guides/es.md create mode 100644 hack/i18n/style-guides/hi.md create mode 100644 hack/i18n/style-guides/pt-br.md create mode 100644 hack/i18n/style-guides/ru.md create mode 100644 hack/i18n/style-guides/zh-cn.md create mode 100644 hack/i18n/translate.py create mode 100644 hack/i18n/worklist.py create mode 100644 i18n/es.toml create mode 100644 i18n/pt-br.toml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ea19e833..74cc2e6d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,16 @@ +# Default owners for everything in the repo. * @kvaps @lllamnyp + +# Localized content is produced and updated by the automated translation +# pipeline (hack/i18n/, .github/workflows/i18n-translate.yml) under a +# publish-then-review model: machine translations ship immediately and native +# owners ratify asynchronously. These paths are intentionally exempt from the +# default CODEOWNERS review so the pipeline can auto-merge translation updates. +# Code, layouts, config, English source, and the pipeline itself stay owned by +# the maintainers. +/content/de/ +/content/ru/ +/content/zh-cn/ +/content/hi/ +/content/es/ +/content/pt-br/ diff --git a/.github/workflows/i18n-translate.yml b/.github/workflows/i18n-translate.yml new file mode 100644 index 00000000..6a992db3 --- /dev/null +++ b/.github/workflows/i18n-translate.yml @@ -0,0 +1,87 @@ +name: i18n translate + +# Automated translation pipeline. Detects English pages that are new or whose +# source changed (via source_digest), translates them into every configured +# language with Claude, and publishes the result. Publish mode is set in +# hack/i18n/config.yaml (auto_merge → opens a PR and enables auto-merge; content/ +# / is exempt from CODEOWNERS so translations merge without a human gate, +# while code/layouts stay protected). + +on: + schedule: + - cron: "0 3 * * *" # nightly + workflow_dispatch: + inputs: + lang: + description: "Restrict to one language code (blank = all)" + required: false + limit: + description: "Max pages this run (blank = no limit)" + required: false + +permissions: + contents: write + pull-requests: write + +concurrency: + group: i18n-translate + cancel-in-progress: false + +jobs: + translate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install anthropic pyyaml + + - name: Refresh Ahrefs keyword maps + env: + AHREFS_API_KEY: ${{ secrets.AHREFS_API_KEY }} + run: python3 hack/i18n/ahrefs_keywords.py ${{ github.event.inputs.lang && format('--lang {0}', github.event.inputs.lang) || '' }} + + - name: Translate missing / stale pages + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + python3 hack/i18n/translate.py \ + ${{ github.event.inputs.lang && format('--lang {0}', github.event.inputs.lang) || '' }} \ + ${{ github.event.inputs.limit && format('--limit {0}', github.event.inputs.limit) || '' }} + + - name: Verify i18n freshness & parity + run: ./hack/check-i18n.sh + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if git diff --quiet; then + echo "No translation changes — nothing to publish." + exit 0 + fi + BRANCH="i18n/auto-$(git rev-parse --short HEAD)-${GITHUB_RUN_ID}" + git config user.name "cozystack-i18n-bot" + git config user.email "noreply@cozystack.io" + git checkout -b "$BRANCH" + git add content/ + git commit --signoff -m "i18n: automated translation update + +Machine-translated new/updated pages (publish-then-review). Native owners +ratify asynchronously; source_digest tracks freshness against English." + git push origin "$BRANCH" + MODE=$(python3 -c "import sys;sys.path.insert(0,'hack/i18n');import lib;print(lib.load_config()['publish_mode'])") + gh pr create --base main --head "$BRANCH" \ + --title "i18n: automated translation update" \ + --body "Automated by the i18n translation pipeline. Only \`content//\` is touched (CODEOWNERS-exempt). Freshness and key parity verified by \`check-i18n.sh\`." + if [ "$MODE" = "auto_merge" ]; then + gh pr merge "$BRANCH" --auto --squash || \ + echo "::warning::auto-merge not enabled/allowed by branch protection — PR left open for a maintainer" + fi diff --git a/config/production/hugo.yaml b/config/production/hugo.yaml index 03461200..0f0f83aa 100644 --- a/config/production/hugo.yaml +++ b/config/production/hugo.yaml @@ -72,6 +72,18 @@ module: files: - '! **/_include/*' - '! docs/next/**' + - source: content/es + target: content + lang: es + files: + - '! **/_include/*' + - '! docs/next/**' + - source: content/pt-br + target: content + lang: pt-br + files: + - '! **/_include/*' + - '! docs/next/**' - source: node_modules/bootstrap target: assets/vendor/bootstrap - source: node_modules/@fortawesome/fontawesome-free diff --git a/hack/i18n/.gitignore b/hack/i18n/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/hack/i18n/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/hack/i18n/README.md b/hack/i18n/README.md new file mode 100644 index 00000000..a41dc74a --- /dev/null +++ b/hack/i18n/README.md @@ -0,0 +1,76 @@ +# Cozystack website translation pipeline + +Automated, in-tree localization for cozystack.io. Detects English pages that are +new or changed, translates them into every configured language with Claude, and +publishes under a **publish-then-review** model — machine translations ship +immediately; native owners ratify asynchronously. + +Lives in the website repo (vendor-neutral, community-inspectable) alongside the +existing `hack/check-i18n.sh` CI guard. + +## How it works + +``` +content/en/** ──▶ worklist.py (diff via source_digest: missing | stale) + │ + ▼ + translate.py (Claude Opus + glossary + style guide + │ + Ahrefs keyword map for SEO front matter) + ▼ + content//** (source_digest stamped) + │ + check-i18n.sh (freshness + i18n key parity) + │ + i18n-translate.yml (PR → auto-merge; content/ is + CODEOWNERS-exempt) +``` + +Freshness is a `source_digest: "sha256:"` front-matter field on every +translated page — the sha256 of its English source. When the English page +changes, the digest no longer matches and the page is re-translated. This is the +same convention `hack/check-i18n.sh` enforces, so the pipeline and the CI lint +agree by construction. + +## Files + +| Path | Purpose | +|------|---------| +| `config.yaml` | languages, model routing, scope globs, blog cutoff, publish mode, Ahrefs | +| `glossary.yaml` | do-not-translate terms (brands/CLI/APIs) + preferred per-language terms | +| `prompts/translate.md` | translation system prompt (glossary + style + output protocol) | +| `style-guides/.md` | per-language tone/register conventions | +| `keyword-maps/.yaml` | Ahrefs-derived SEO keyword targets (generated) | +| `lib.py` | shared helpers (config, file discovery, digest, front matter, protect/restore) | +| `worklist.py` | diff detector — what needs translation | +| `translate.py` | the translator (Claude API) | +| `ahrefs_keywords.py` | regenerate SEO keyword maps from Ahrefs | + +## Running locally + +```bash +pip install anthropic pyyaml +export ANTHROPIC_API_KEY=... # required for translate.py +export AHREFS_API_KEY=... # optional; without it, SEO is translated literally + +python3 hack/i18n/worklist.py # what would change +python3 hack/i18n/translate.py --dry-run # plan, no API calls +python3 hack/i18n/translate.py --lang ru --limit 5 # translate a few RU pages +./hack/check-i18n.sh # verify freshness + key parity +``` + +## Scope + +- **Docs:** latest version only (`docs/v1.4/**`); older versions stay English (they are `noindex`). +- **Blog:** posts newer than `blog_since` in `config.yaml` (rolling window; the full archive is out of scope by default). +- **Never:** `docs/next/**`, `**/_include/**`. +- Code blocks, shortcodes, HTML comments, inline code, URLs, CLI, YAML keys, and brand names are preserved structurally or via the glossary. + +## Governance + +- `content//` is exempt from `.github/CODEOWNERS`, so the pipeline auto-merges translations. Code, layouts, config, English source, and this pipeline stay maintainer-owned. +- If branch protection requires approvals beyond CODEOWNERS, auto-merge falls back to leaving the PR open for a maintainer (the workflow logs a warning). Set `publish_mode: pr_only` in `config.yaml` to always require a human merge. + +## Secrets (GitHub Actions) + +- `ANTHROPIC_API_KEY` — required. +- `AHREFS_API_KEY` — optional (SEO keyword localization + monitoring). diff --git a/hack/i18n/ahrefs_keywords.py b/hack/i18n/ahrefs_keywords.py new file mode 100644 index 00000000..3b757c31 --- /dev/null +++ b/hack/i18n/ahrefs_keywords.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Generate per-language SEO keyword maps from Ahrefs, for SEO transcreation. + +For a curated set of seed topics (derived from the English pages), query the +Ahrefs Keywords Explorer for the target country/language and record the highest- +value localized keywords. translate.py injects these into the front-matter +transcreation prompt so localized title/description target real search demand +instead of a literal translation. + +Requires AHREFS_API_KEY. Writes hack/i18n/keyword-maps/.yaml, keyed by the +page's relative path. Degrades gracefully: without a key it writes/keeps empty +maps and the pipeline falls back to literal SEO translation. + +Usage: + ahrefs_keywords.py [--lang es] [--dry-run] + +NOTE: Ahrefs API v3 endpoint/field names should be confirmed against the current +docs when the subscription is active; the request shape is isolated in +`_ahrefs_keywords()` for easy adjustment. +""" + +from __future__ import annotations + +import argparse +import os +import sys + +import yaml + +import lib + +AHREFS_API = "https://api.ahrefs.com/v3/keywords-explorer/overview" + +# Seed topics per page (relative path -> English seed phrases). Kept small and +# explicit rather than auto-derived, so keyword targeting is deliberate. Extend +# as more pages are prioritized for SEO. +SEED_TOPICS: dict[str, list[str]] = { + "_index.html": ["kubernetes platform", "private cloud", "bare metal kubernetes"], + "docs/v1.4/getting-started/_index.md": ["install kubernetes", "kubernetes getting started"], + "support.md": ["kubernetes support", "enterprise kubernetes support"], +} + + +def _ahrefs_keywords(seeds: list[str], country: str, api_key: str) -> list[str]: + """Return localized keyword suggestions for seeds in a target country.""" + import urllib.parse + import urllib.request + + found: list[str] = [] + for seed in seeds: + params = urllib.parse.urlencode({ + "keywords": seed, + "country": country, + "select": "keyword,volume,difficulty", + }) + req = urllib.request.Request( + f"{AHREFS_API}?{params}", + headers={"Authorization": f"Bearer {api_key}", + "Accept": "application/json"}, + ) + try: + import json + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.load(resp) + for row in data.get("keywords", data.get("data", [])): + kw = row.get("keyword") + if kw: + found.append(kw) + except Exception as exc: # never hard-fail keyword research + print(f"::warning::ahrefs lookup failed for '{seed}' ({country}): {exc}", + file=sys.stderr) + # de-dup, keep order, cap + seen, out = set(), [] + for kw in found: + if kw not in seen: + seen.add(kw) + out.append(kw) + return out[:10] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--lang") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + cfg = lib.load_config() + if not cfg["ahrefs"].get("enabled"): + print("ahrefs disabled in config; nothing to do") + return 0 + + api_key = os.environ.get("AHREFS_API_KEY") + country_by_lang = cfg["ahrefs"]["country_by_lang"] + out_dir = os.path.join(lib.REPO_ROOT, cfg["ahrefs"]["keyword_map_dir"]) + os.makedirs(out_dir, exist_ok=True) + + langs = [l for l in cfg["languages"] if args.lang in (None, l["code"])] + for lang_cfg in langs: + code = lang_cfg["code"] + country = country_by_lang.get(code, "us") + kmap: dict[str, list[str]] = {} + for rel, seeds in SEED_TOPICS.items(): + if args.dry_run or not api_key: + kmap[rel] = [] # placeholder; literal SEO fallback in translate.py + else: + kmap[rel] = _ahrefs_keywords(seeds, country, api_key) + dest = os.path.join(out_dir, f"{code}.yaml") + with open(dest, "w", encoding="utf-8") as fh: + yaml.safe_dump(kmap, fh, allow_unicode=True, sort_keys=True) + note = "placeholder" if (args.dry_run or not api_key) else "from Ahrefs" + print(f" wrote {dest} ({note})") + + if not api_key and not args.dry_run: + print("::warning::AHREFS_API_KEY not set — wrote empty maps; " + "SEO front matter will be translated literally", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hack/i18n/config.yaml b/hack/i18n/config.yaml new file mode 100644 index 00000000..a57f213c --- /dev/null +++ b/hack/i18n/config.yaml @@ -0,0 +1,93 @@ +# Cozystack website translation pipeline — configuration. +# +# Single source of truth for the automated i18n pipeline: which languages +# exist, which model translates which content, and which paths are in scope. +# Consumed by hack/i18n/*.py and .github/workflows/i18n-translate.yml. + +source_lang: en +content_dir: content + +# Target languages. `code` must match the Hugo `languages:` key in hugo.yaml +# and the i18n/.toml filename. `hreflang` is the BCP-47 tag emitted in +# . +languages: + - code: de + name: Deutsch + hreflang: de + - code: ru + name: Русский + hreflang: ru + - code: zh-cn + name: 简体中文 + hreflang: zh-Hans + - code: hi + name: हिन्दी + hreflang: hi + - code: es + name: Español + hreflang: es + - code: pt-br + name: Português (Brasil) + hreflang: pt-BR + +# Model routing. All content is translated with Opus for maximum quality +# (decision 2026-07-17). `hero_globs` may still be split onto a stronger tier +# later; today both point at the same model so the routing is a no-op we can +# tune without touching code. +model: + default: claude-opus-4-8 + hero: claude-opus-4-8 + # Pages matched by these globs are "hero" pages (transcreation, not literal + # translation): landing, top-level docs indexes, blog announcements. + hero_globs: + - "_index.html" + - "_index.md" + - "blog/**/index.md" + max_output_tokens: 16000 + temperature: 0.2 + +# Scope: only the latest docs version is translated; older versions stay +# English (they are noindex anyway). `next` (unreleased) is never translated. +# +# Blog: only recent posts are worth translating × 6 languages. `blog_since` +# drops any blog post whose date-prefixed path (blog/YYYY-MM-DD-…) is older +# than this cutoff. Bump it (or have the workflow compute now-60d) to roll the +# window forward. Set to empty string to translate the entire blog archive. +blog_since: "2026-05-17" +translate_globs: + - "_index.html" + - "_index.md" + - "*.md" + - "docs/v1.4/**" + - "blog/**" + - "support.md" +exclude_globs: + - "docs/v0/**" + - "docs/v1.0/**" + - "docs/v1.1/**" + - "docs/v1.2/**" + - "docs/v1.3/**" + - "docs/next/**" + - "**/_include/**" + +# Publish mode: the pipeline auto-merges translations because content// +# is exempt from CODEOWNERS (see .github/CODEOWNERS). Code/layouts stay gated. +# auto_merge: open a PR and enable GitHub auto-merge +# pr_only: open a PR and stop (a human merges) +publish_mode: auto_merge + +# Ahrefs SEO/GEO. Keyword maps localize SEO front matter (title/description/ +# keywords) via transcreation rather than literal translation. Regenerated on +# demand; requires AHREFS_API_KEY. Absent a key, the pipeline falls back to +# literal SEO translation and logs a warning (never hard-fails). +ahrefs: + enabled: true + keyword_map_dir: hack/i18n/keyword-maps + # Ahrefs country codes per language, for volume/difficulty lookups. + country_by_lang: + de: de + ru: ru + zh-cn: cn + hi: in + es: es + pt-br: br diff --git a/hack/i18n/glossary.yaml b/hack/i18n/glossary.yaml new file mode 100644 index 00000000..53a08254 --- /dev/null +++ b/hack/i18n/glossary.yaml @@ -0,0 +1,114 @@ +# Cozystack translation glossary. +# +# Two lists: +# do_not_translate — terms kept verbatim in every language (brands, product +# names, protocols, CLIs, APIs). The translator is instructed to never +# translate, transliterate, or inflect these. +# preferred — for terms that DO get translated, the canonical rendering per +# language, so translations stay consistent across pages. Derived from the +# CNCF Cloud Native Glossary and kubernetes.io localization teams. +# +# Code blocks, inline `code`, Hugo shortcodes ({{< >}} / {{% %}}), URLs, and +# front-matter keys are protected structurally by the pipeline (see lib.py), +# not via this list. + +do_not_translate: + # Project / brand + - Cozystack + - Ænix + - Aenix + # CNCF ecosystem + - Kubernetes + - k8s + - KubeVirt + - Cilium + - LINSTOR + - Talos + - Talos Linux + - FluxCD + - Flux + - Argo CD + - VictoriaMetrics + - VictoriaLogs + - Helm + - Prometheus + - Grafana + - etcd + - containerd + - CNCF + # Vendors / external + - VMware + - vSphere + - Proxmox + - OpenStack + - AWS + - GCP + - Azure + - Broadcom + # Protocols / specs / formats + - CSI + - CNI + - CRI + - OCI + - API + - REST + - gRPC + - YAML + - JSON + - TLS + - DNS + - SBOM + - CSAF + - VEX + - SLSA + # Cozystack nouns kept as-is + - Package + - PackageSource + - ApplicationDefinition + - Tenant + - SchedulingClass + +# Preferred translations for translatable terms. Keys are the English term; +# values are per-language canonical renderings. Omitted languages fall back to +# the translator's judgement (still consistent within a run via glossary hint). +preferred: + cluster: + ru: кластер + de: Cluster + es: clúster + pt-br: cluster + node: + ru: узел + de: Knoten + es: nodo + pt-br: nó + storage: + ru: хранилище + de: Speicher + es: almacenamiento + pt-br: armazenamento + workload: + ru: рабочая нагрузка + de: Workload + es: carga de trabajo + pt-br: carga de trabalho + deployment: + ru: развёртывание + de: Deployment + es: despliegue + pt-br: implantação + virtual machine: + ru: виртуальная машина + de: virtuelle Maschine + es: máquina virtual + pt-br: máquina virtual + bare metal: + ru: bare-metal + de: Bare-Metal + es: bare metal + pt-br: bare metal + tenant: + ru: тенант + de: Mandant + es: inquilino (tenant) + pt-br: tenant diff --git a/hack/i18n/keyword-maps/de.yaml b/hack/i18n/keyword-maps/de.yaml new file mode 100644 index 00000000..78a56ee4 --- /dev/null +++ b/hack/i18n/keyword-maps/de.yaml @@ -0,0 +1,3 @@ +_index.html: [] +docs/v1.4/getting-started/_index.md: [] +support.md: [] diff --git a/hack/i18n/keyword-maps/es.yaml b/hack/i18n/keyword-maps/es.yaml new file mode 100644 index 00000000..78a56ee4 --- /dev/null +++ b/hack/i18n/keyword-maps/es.yaml @@ -0,0 +1,3 @@ +_index.html: [] +docs/v1.4/getting-started/_index.md: [] +support.md: [] diff --git a/hack/i18n/keyword-maps/hi.yaml b/hack/i18n/keyword-maps/hi.yaml new file mode 100644 index 00000000..78a56ee4 --- /dev/null +++ b/hack/i18n/keyword-maps/hi.yaml @@ -0,0 +1,3 @@ +_index.html: [] +docs/v1.4/getting-started/_index.md: [] +support.md: [] diff --git a/hack/i18n/keyword-maps/pt-br.yaml b/hack/i18n/keyword-maps/pt-br.yaml new file mode 100644 index 00000000..78a56ee4 --- /dev/null +++ b/hack/i18n/keyword-maps/pt-br.yaml @@ -0,0 +1,3 @@ +_index.html: [] +docs/v1.4/getting-started/_index.md: [] +support.md: [] diff --git a/hack/i18n/keyword-maps/ru.yaml b/hack/i18n/keyword-maps/ru.yaml new file mode 100644 index 00000000..78a56ee4 --- /dev/null +++ b/hack/i18n/keyword-maps/ru.yaml @@ -0,0 +1,3 @@ +_index.html: [] +docs/v1.4/getting-started/_index.md: [] +support.md: [] diff --git a/hack/i18n/keyword-maps/zh-cn.yaml b/hack/i18n/keyword-maps/zh-cn.yaml new file mode 100644 index 00000000..78a56ee4 --- /dev/null +++ b/hack/i18n/keyword-maps/zh-cn.yaml @@ -0,0 +1,3 @@ +_index.html: [] +docs/v1.4/getting-started/_index.md: [] +support.md: [] diff --git a/hack/i18n/lib.py b/hack/i18n/lib.py new file mode 100644 index 00000000..763da90b --- /dev/null +++ b/hack/i18n/lib.py @@ -0,0 +1,189 @@ +"""Shared helpers for the Cozystack website translation pipeline. + +Pure-stdlib except PyYAML. No Hugo/Node required. Everything keys off the same +`source_digest` (sha256 of the English source) convention that hack/check-i18n.sh +already enforces, so the automated pipeline and the CI lint agree by construction. +""" + +from __future__ import annotations + +import fnmatch +import hashlib +import os +import re +from dataclasses import dataclass + +import yaml + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.yaml") +GLOSSARY_PATH = os.path.join(os.path.dirname(__file__), "glossary.yaml") + +# Front-matter values we translate. Everything else (slug, date, weight, +# aliases, images, source_digest, params, …) is preserved verbatim. +TRANSLATABLE_FRONTMATTER_KEYS = ("title", "linkTitle", "description", "summary") + +# Protected spans: replaced with opaque placeholders before the model sees the +# text, restored afterwards. Order matters (fenced code before inline code). +_PROTECT_PATTERNS = [ + ("FENCE", re.compile(r"```.*?```", re.DOTALL)), + ("SHORTCODE", re.compile(r"\{\{[<%].*?[%>]\}\}", re.DOTALL)), + ("HTMLCOMMENT", re.compile(r"", re.DOTALL)), + ("INLINECODE", re.compile(r"`[^`\n]+`")), +] + + +def load_config() -> dict: + with open(CONFIG_PATH, encoding="utf-8") as fh: + return yaml.safe_load(fh) + + +def load_glossary() -> dict: + with open(GLOSSARY_PATH, encoding="utf-8") as fh: + return yaml.safe_load(fh) + + +def sha256_file(path: str) -> str: + """sha256 hex of raw file bytes — matches hack/check-i18n.sh.""" + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _matches_any(rel: str, globs: list[str]) -> bool: + return any(fnmatch.fnmatch(rel, g) or fnmatch.fnmatch(rel, f"**/{g}") for g in globs) + + +_BLOG_DATE_RE = re.compile(r"^blog/(\d{4}-\d{2}-\d{2})-") + + +def _blog_too_old(rel: str, blog_since: str) -> bool: + """True if rel is a blog post dated before the blog_since cutoff.""" + if not blog_since: + return False + m = _BLOG_DATE_RE.match(rel) + return bool(m) and m.group(1) < blog_since + + +def iter_source_files(cfg: dict) -> list[str]: + """Relative paths (under content/en) in scope for translation.""" + src_root = os.path.join(REPO_ROOT, cfg["content_dir"], cfg["source_lang"]) + out: list[str] = [] + for dirpath, _dirs, files in os.walk(src_root): + for name in files: + if not name.endswith((".md", ".html")): + continue + rel = os.path.relpath(os.path.join(dirpath, name), src_root) + rel = rel.replace(os.sep, "/") + if cfg.get("exclude_globs") and _matches_any(rel, cfg["exclude_globs"]): + continue + if _blog_too_old(rel, cfg.get("blog_since", "")): + continue + if _matches_any(rel, cfg["translate_globs"]): + out.append(rel) + return sorted(out) + + +def source_path(cfg: dict, rel: str) -> str: + return os.path.join(REPO_ROOT, cfg["content_dir"], cfg["source_lang"], rel) + + +def target_path(cfg: dict, lang: str, rel: str) -> str: + return os.path.join(REPO_ROOT, cfg["content_dir"], lang, rel) + + +def is_hero(cfg: dict, rel: str) -> bool: + return _matches_any(rel, cfg["model"].get("hero_globs", [])) + + +@dataclass +class WorkItem: + lang: str + rel: str + reason: str # "missing" | "stale" + + +def recorded_digest(path: str) -> str | None: + """Read source_digest from a translated file's front matter, if present.""" + if not os.path.exists(path): + return None + with open(path, encoding="utf-8") as fh: + head = fh.read(4096) + m = re.search(r"^source_digest:\s*\"?sha256:([0-9a-fA-F]+)\"?", head, re.MULTILINE) + return m.group(1) if m else None + + +def build_worklist(cfg: dict, only_lang: str | None = None) -> list[WorkItem]: + items: list[WorkItem] = [] + langs = [l["code"] for l in cfg["languages"] if only_lang in (None, l["code"])] + for rel in iter_source_files(cfg): + cur = sha256_file(source_path(cfg, rel)) + for lang in langs: + tp = target_path(cfg, lang, rel) + if not os.path.exists(tp): + items.append(WorkItem(lang, rel, "missing")) + elif recorded_digest(tp) != cur: + items.append(WorkItem(lang, rel, "stale")) + return items + + +# ---- front matter ----------------------------------------------------------- + +_FM_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL) + + +def split_frontmatter(text: str) -> tuple[dict | None, str, str]: + """Return (frontmatter_dict, body, raw_frontmatter). dict is None if absent. + + Only YAML front matter (--- fences) is handled; Cozystack content uses YAML. + """ + m = _FM_RE.match(text) + if not m: + return None, text, "" + raw = m.group(1) + try: + fm = yaml.safe_load(raw) or {} + except yaml.YAMLError: + fm = None + return fm, m.group(2), raw + + +# ---- protect / restore ------------------------------------------------------ + + +def protect(text: str) -> tuple[str, dict[str, str]]: + """Replace code/shortcodes/comments with placeholders. Returns (masked, map).""" + store: dict[str, str] = {} + counter = 0 + + def _sub(kind): + def repl(match): + nonlocal counter + token = f"§§{kind}_{counter}§§" + store[token] = match.group(0) + counter += 1 + return token + return repl + + for kind, pat in _PROTECT_PATTERNS: + text = pat.sub(_sub(kind), text) + return text, store + + +def restore(text: str, store: dict[str, str]) -> str: + for token, original in store.items(): + text = text.replace(token, original) + return text + + +def set_source_digest(front_lines: list[str], digest_hex: str) -> list[str]: + """Insert/replace source_digest line in a list of front-matter YAML lines.""" + line = f'source_digest: "sha256:{digest_hex}"' + for i, l in enumerate(front_lines): + if l.startswith("source_digest:"): + front_lines[i] = line + return front_lines + front_lines.append(line) + return front_lines diff --git a/hack/i18n/prompts/translate.md b/hack/i18n/prompts/translate.md new file mode 100644 index 00000000..288dd667 --- /dev/null +++ b/hack/i18n/prompts/translate.md @@ -0,0 +1,46 @@ +You are a professional technical translator localizing the Cozystack website +(a CNCF cloud-native platform: Kubernetes, virtualization, storage, networking) +from English into {{LANGUAGE}} ({{LANG_CODE}}). + +Translate for a technical audience — platform engineers, SREs, CTOs. Produce +natural, idiomatic {{LANGUAGE}} that a native engineer would write, not a literal +word-for-word rendering. Preserve the author's meaning, technical precision, and +tone. When a sentence is marketing copy (landing/blog), transcreate it so it +reads well in {{LANGUAGE}}; when it is documentation, stay precise and faithful. + +## Hard rules + +1. NEVER translate, transliterate, or inflect these terms — keep them exactly as + written, in Latin script: {{DO_NOT_TRANSLATE}} +2. NEVER alter placeholders of the form §§NAME_N§§ — copy them through verbatim, + in the same positions. They stand for code blocks, shortcodes, HTML comments, + and inline code that must not change. +3. NEVER change URLs, file paths, CLI commands, flags, API field names, or YAML + keys. Translate surrounding prose only. +4. Preserve all Markdown/HTML structure exactly: heading levels, list markers, + tables (same number of columns and delimiter cells), links (translate link + TEXT, keep the URL), bold/italic, and blank lines. +5. Keep numbers, versions, dates, and units unchanged. + +## Preferred terminology (use consistently) + +{{PREFERRED_TERMS}} + +## Style guide for {{LANGUAGE}} + +{{STYLE_GUIDE}} + +## Output protocol + +The user message contains a FRONTMATTER section (a few `key: value` lines) and a +BODY section, separated by the exact markers `===FRONTMATTER===` and +`===BODY===`. Respond with the SAME two markers and nothing else: + +===FRONTMATTER=== + +===BODY=== + + +For SEO fields (title, description): make them read naturally in {{LANGUAGE}} and +incorporate any provided keyword targets, but never keyword-stuff. Do not add, +remove, or reorder front-matter keys. Do not wrap your answer in code fences. diff --git a/hack/i18n/style-guides/de.md b/hack/i18n/style-guides/de.md new file mode 100644 index 00000000..48dd72dc --- /dev/null +++ b/hack/i18n/style-guides/de.md @@ -0,0 +1,5 @@ +- Formal register: address the reader with "Sie". +- Follow kubernetes.io German localization conventions. +- Compound nouns: keep them readable; hyphenate mixed English-German compounds (e.g. "Kubernetes-Cluster", "Bare-Metal-Server"). +- Keep established English infra loanwords where German has no natural equivalent (Deployment, Workload, Pod, Container). +- Use „German quotation marks". diff --git a/hack/i18n/style-guides/es.md b/hack/i18n/style-guides/es.md new file mode 100644 index 00000000..1eb8d74f --- /dev/null +++ b/hack/i18n/style-guides/es.md @@ -0,0 +1,5 @@ +- Neutral international Spanish (usable across Spain and Latin America); avoid region-specific slang. +- Address the reader with "tú" for docs/marketing (industry-accepted, friendly-professional) — consistent throughout. +- Follow kubernetes.io Spanish localization conventions. +- Keep English infra loanwords common in the ecosystem (pod, deployment, cluster→clúster, workload→carga de trabajo). +- Use inverted punctuation (¿ ¡) correctly. diff --git a/hack/i18n/style-guides/hi.md b/hack/i18n/style-guides/hi.md new file mode 100644 index 00000000..0ffcc103 --- /dev/null +++ b/hack/i18n/style-guides/hi.md @@ -0,0 +1,3 @@ +- Modern standard Hindi (Devanagari) as used in Indian tech writing; Hinglish is common — keep widely-used English technical terms in Latin script rather than forcing Sanskritized coinages. +- Keep ALL technical/product terms and most infra vocabulary in English/Latin (Kubernetes, cluster, pod, deployment, storage) — translate connective prose, not jargon. +- Professional, clear register. diff --git a/hack/i18n/style-guides/pt-br.md b/hack/i18n/style-guides/pt-br.md new file mode 100644 index 00000000..1cff6cc6 --- /dev/null +++ b/hack/i18n/style-guides/pt-br.md @@ -0,0 +1,5 @@ +- Brazilian Portuguese (pt-BR), not European Portuguese. +- Address the reader with "você"; friendly-professional register. +- Follow kubernetes.io Brazilian Portuguese localization conventions. +- Keep English infra loanwords common in the BR tech community (pod, deploy/deployment, cluster, workload→carga de trabalho). +- Natural BR phrasing over literal calques. diff --git a/hack/i18n/style-guides/ru.md b/hack/i18n/style-guides/ru.md new file mode 100644 index 00000000..237450d1 --- /dev/null +++ b/hack/i18n/style-guides/ru.md @@ -0,0 +1,5 @@ +- Обращение к читателю — на «вы» со строчной буквы (нейтрально-профессионально). +- Инфинитивные заголовки («Установить кластер»), как в kubernetes.io/ru. +- Англицизмы, укоренившиеся в инфре, не переводить силой: «под» (pod — можно оставлять pod в коде), «нода»/«узел» — «узел» в прозе, «деплой»/«развёртывание» — предпочитать «развёртывание». +- Кавычки — «ёлочки». Тире — длинное (—). +- Не переводить: kubectl, namespace (в командах), CLI-флаги. diff --git a/hack/i18n/style-guides/zh-cn.md b/hack/i18n/style-guides/zh-cn.md new file mode 100644 index 00000000..78654b3a --- /dev/null +++ b/hack/i18n/style-guides/zh-cn.md @@ -0,0 +1,4 @@ +- Simplified Chinese (zh-Hans), mainland conventions; follow kubernetes.io/zh-cn glossary. +- No spaces between Chinese characters; put a space between Chinese and Latin/number runs. +- Use full-width Chinese punctuation (,。:;"")for Chinese text; keep half-width for code/commands. +- Keep product/brand/CLI terms in Latin; translate prose. Common infra terms per k8s zh glossary (集群=cluster, 节点=node, 存储=storage, 工作负载=workload). diff --git a/hack/i18n/translate.py b/hack/i18n/translate.py new file mode 100644 index 00000000..993a7e02 --- /dev/null +++ b/hack/i18n/translate.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Translate Cozystack website pages into the configured languages. + +For each page that is missing or stale (see worklist.py) this: + 1. splits YAML front matter from the body; + 2. protects code fences, shortcodes, HTML comments and inline code; + 3. asks Claude (Opus) to translate the body and a whitelist of front-matter + values, guided by the glossary, the per-language style guide, and — when + available — the Ahrefs keyword map for SEO front matter; + 4. restores protected spans, stamps `source_digest`, and writes the file. + +Requires ANTHROPIC_API_KEY. Idempotent: rerunning only touches missing/stale +pages. `--dry-run` prints the plan without calling the API or writing files. + +Usage: + translate.py [--lang ru] [--limit N] [--dry-run] +""" + +from __future__ import annotations + +import argparse +import os +import sys + +import yaml + +import lib + +PROMPT_DIR = os.path.join(os.path.dirname(__file__), "prompts") +STYLE_DIR = os.path.join(os.path.dirname(__file__), "style-guides") + + +def _read(path: str, default: str = "") -> str: + return open(path, encoding="utf-8").read() if os.path.exists(path) else default + + +def load_keyword_map(cfg: dict, lang: str) -> dict: + path = os.path.join(lib.REPO_ROOT, cfg["ahrefs"]["keyword_map_dir"], f"{lang}.yaml") + if os.path.exists(path): + return yaml.safe_load(open(path, encoding="utf-8")) or {} + return {} + + +def build_system_prompt(cfg: dict, lang_cfg: dict, glossary: dict) -> str: + base = _read(os.path.join(PROMPT_DIR, "translate.md")) + style = _read(os.path.join(STYLE_DIR, f"{lang_cfg['code']}.md"), + f"(no style guide for {lang_cfg['code']} yet — use professional, natural {lang_cfg['name']}.)") + dnt = ", ".join(glossary.get("do_not_translate", [])) + preferred_lines = [] + for term, per_lang in (glossary.get("preferred") or {}).items(): + if lang_cfg["code"] in per_lang: + preferred_lines.append(f" - {term} → {per_lang[lang_cfg['code']]}") + preferred = "\n".join(preferred_lines) or " (none specified)" + return ( + base + .replace("{{LANGUAGE}}", lang_cfg["name"]) + .replace("{{LANG_CODE}}", lang_cfg["code"]) + .replace("{{DO_NOT_TRANSLATE}}", dnt) + .replace("{{PREFERRED_TERMS}}", preferred) + .replace("{{STYLE_GUIDE}}", style) + ) + + +def translate_text(client, model: str, cfg: dict, system: str, payload: str) -> str: + msg = client.messages.create( + model=model, + max_tokens=cfg["model"]["max_output_tokens"], + temperature=cfg["model"]["temperature"], + system=system, + messages=[{"role": "user", "content": payload}], + ) + return "".join(block.text for block in msg.content if block.type == "text").strip() + + +def translate_page(client, cfg, glossary, lang_cfg, rel) -> str: + src = lib.source_path(cfg, rel) + text = open(src, encoding="utf-8").read() + fm, body, raw_fm = lib.split_frontmatter(text) + + masked_body, store = lib.protect(body) + model = cfg["model"]["hero"] if lib.is_hero(cfg, rel) else cfg["model"]["default"] + system = build_system_prompt(cfg, lang_cfg, glossary) + + # Front-matter values to transcreate (SEO-aware), plus the body, in one call + # using a simple delimiter protocol the prompt describes. + fm_values = {} + if isinstance(fm, dict): + for k in lib.TRANSLATABLE_FRONTMATTER_KEYS: + if k in fm and isinstance(fm[k], str): + fm_values[k] = fm[k] + + kw = load_keyword_map(cfg, lang_cfg["code"]) + kw_hint = "" + if kw.get(rel): + kw_hint = ("\n\nSEO keyword targets for this page (transcreate title/" + "description toward these, do not stuff): " + + ", ".join(kw[rel])) + + payload = ( + "Translate the FRONTMATTER values and the BODY below.\n" + "Return EXACTLY this structure:\n" + "===FRONTMATTER===\n\n" + "===BODY===\n\n" + + kw_hint + + "\n\n===FRONTMATTER===\n" + + "\n".join(f"{k}: {v}" for k, v in fm_values.items()) + + "\n===BODY===\n" + + masked_body + ) + + out = translate_text(client, model, cfg, system, payload) + + # Parse the model's response back into front matter + body. + tr_fm, tr_body = {}, out + if "===BODY===" in out: + head, tr_body = out.split("===BODY===", 1) + for line in head.replace("===FRONTMATTER===", "").strip().splitlines(): + if ":" in line: + k, _, v = line.partition(":") + tr_fm[k.strip()] = v.strip() + tr_body = lib.restore(tr_body.strip(), store) + + # Reassemble front matter: preserve everything, override translated keys, + # stamp source_digest. + out_fm = dict(fm) if isinstance(fm, dict) else {} + for k in fm_values: + if tr_fm.get(k): + out_fm[k] = tr_fm[k] + out_fm["source_digest"] = f"sha256:{lib.sha256_file(src)}" + + fm_yaml = yaml.safe_dump(out_fm, allow_unicode=True, sort_keys=False).strip() + return f"---\n{fm_yaml}\n---\n{tr_body}\n" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--lang") + ap.add_argument("--limit", type=int) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + cfg = lib.load_config() + glossary = lib.load_glossary() + lang_by_code = {l["code"]: l for l in cfg["languages"]} + items = lib.build_worklist(cfg, only_lang=args.lang) + if args.limit: + items = items[: args.limit] + + print(f"{len(items)} page(s) to translate" + f"{' (dry run)' if args.dry_run else ''}") + if args.dry_run: + for it in items: + print(f" [{it.reason:7}] {it.lang}: {it.rel}") + return 0 + + if not items: + return 0 + + try: + import anthropic + except ImportError: + print("::error::anthropic SDK not installed (pip install anthropic)", file=sys.stderr) + return 1 + if not os.environ.get("ANTHROPIC_API_KEY"): + print("::error::ANTHROPIC_API_KEY not set", file=sys.stderr) + return 1 + client = anthropic.Anthropic() + + failures = 0 + for it in items: + try: + result = translate_page(client, cfg, glossary, lang_by_code[it.lang], it.rel) + dest = lib.target_path(cfg, it.lang, it.rel) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "w", encoding="utf-8") as fh: + fh.write(result) + print(f" translated {it.lang}: {it.rel}") + except Exception as exc: # keep going; one bad page must not stall the run + failures += 1 + print(f"::warning::failed {it.lang}: {it.rel} — {exc}", file=sys.stderr) + + if failures: + print(f"::warning::{failures} page(s) failed", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hack/i18n/worklist.py b/hack/i18n/worklist.py new file mode 100644 index 00000000..b69885b6 --- /dev/null +++ b/hack/i18n/worklist.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Print the translation worklist: pages that are missing or stale per language. + +Usage: + worklist.py # all languages, human-readable summary + worklist.py --json # machine-readable (for the CI matrix) + worklist.py --lang ru # restrict to one language + +"Missing" = no translated file exists. "Stale" = the translated file's +source_digest no longer matches the current sha256 of its English source. +Exit code is always 0; an empty list simply means everything is up to date. +""" + +from __future__ import annotations + +import argparse +import json +import sys + +import lib + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--json", action="store_true", help="emit JSON") + ap.add_argument("--lang", help="restrict to one language code") + args = ap.parse_args() + + cfg = lib.load_config() + items = lib.build_worklist(cfg, only_lang=args.lang) + + if args.json: + json.dump( + [{"lang": i.lang, "rel": i.rel, "reason": i.reason} for i in items], + sys.stdout, + ) + sys.stdout.write("\n") + return 0 + + if not items: + print("translation worklist: empty — all languages up to date") + return 0 + + by_lang: dict[str, list[lib.WorkItem]] = {} + for i in items: + by_lang.setdefault(i.lang, []).append(i) + total = len(items) + print(f"translation worklist: {total} page(s) need work\n") + for lang in sorted(by_lang): + rows = by_lang[lang] + missing = sum(1 for r in rows if r.reason == "missing") + stale = sum(1 for r in rows if r.reason == "stale") + print(f" {lang}: {len(rows)} ({missing} missing, {stale} stale)") + for r in rows: + print(f" [{r.reason:7}] {r.rel}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hugo.yaml b/hugo.yaml index d65e2f1f..3563b6a4 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -91,6 +91,16 @@ module: lang: hi files: - '! **/_include/*' + - source: content/es + target: content + lang: es + files: + - '! **/_include/*' + - source: content/pt-br + target: content + lang: pt-br + files: + - '! **/_include/*' # Mount npm dependencies for Docsy - source: node_modules/bootstrap target: assets/vendor/bootstrap @@ -159,6 +169,20 @@ languages: params: time_format_default: 2006-01-02 time_format_blog: 2006-01-02 + es: + languageName: Español + languageCode: es + weight: 6 + params: + time_format_default: 2006-01-02 + time_format_blog: 2006-01-02 + pt-br: + languageName: Português (Brasil) + languageCode: pt-BR + weight: 7 + params: + time_format_default: 2006-01-02 + time_format_blog: 2006-01-02 permalinks: blog: /:section/:year/:month/:slug/ diff --git a/i18n/es.toml b/i18n/es.toml new file mode 100644 index 00000000..5c0cf69a --- /dev/null +++ b/i18n/es.toml @@ -0,0 +1,53 @@ +[caution] +other = "Precaución:" + +[home_screenshots] +other = "Capturas de pantalla" + +[home_benefits] +other = "Beneficios" + +[home_features] +other = "Funcionalidades" + +[home_cncf_sandbox] +other = "Cozystack es un proyecto Sandbox de la Cloud Native Computing Foundation" + +[join_community] +other = "Únete a la comunidad" + +[docs_create_issue] +other = "Crear incidencia de documentación" + +[docs_create_project_issue] +other = "Crear incidencia del proyecto" + +[docs_edit] +other = "Editar esta página" + +[docs_last_mod] +other = "Última modificación" + +[docs_table_of_contents] +other = "Tabla de contenidos" + +[note] +other = "Nota:" + +[outdated_blog__message] +other = "Este artículo tiene más de un año. Los artículos más antiguos pueden contener contenido desactualizado. Comprueba que la información de la página no se haya vuelto incorrecta desde su publicación." + +[ui_minute_read] +other = "minutos de lectura" + +[ui_pager_next] +other = "Siguiente" + +[ui_pager_prev] +other = "Anterior" + +[ui_read_more] +other = "Leer más" + +[warning] +other = "Advertencia:" diff --git a/i18n/pt-br.toml b/i18n/pt-br.toml new file mode 100644 index 00000000..9212e8c2 --- /dev/null +++ b/i18n/pt-br.toml @@ -0,0 +1,53 @@ +[caution] +other = "Cuidado:" + +[home_screenshots] +other = "Capturas de tela" + +[home_benefits] +other = "Benefícios" + +[home_features] +other = "Recursos" + +[home_cncf_sandbox] +other = "O Cozystack é um projeto Sandbox da Cloud Native Computing Foundation" + +[join_community] +other = "Junte-se à comunidade" + +[docs_create_issue] +other = "Criar issue de documentação" + +[docs_create_project_issue] +other = "Criar issue do projeto" + +[docs_edit] +other = "Editar esta página" + +[docs_last_mod] +other = "Última modificação" + +[docs_table_of_contents] +other = "Sumário" + +[note] +other = "Nota:" + +[outdated_blog__message] +other = "Este artigo tem mais de um ano. Artigos mais antigos podem conter conteúdo desatualizado. Verifique se as informações da página não se tornaram incorretas desde a sua publicação." + +[ui_minute_read] +other = "minutos de leitura" + +[ui_pager_next] +other = "Próximo" + +[ui_pager_prev] +other = "Anterior" + +[ui_read_more] +other = "Leia mais" + +[warning] +other = "Aviso:" diff --git a/layouts/partials/hooks/head-end.html b/layouts/partials/hooks/head-end.html index 0643adaa..3b74e441 100644 --- a/layouts/partials/hooks/head-end.html +++ b/layouts/partials/hooks/head-end.html @@ -37,6 +37,19 @@ {{- end }} +{{/* SEO: hreflang alternates. Emitted only when a page has translations, so + search engines and AI crawlers map the per-language versions to each other. + Language codes come from `languageCode` in hugo.yaml (proper BCP-47: + zh-Hans, pt-BR, …). English doubles as x-default. */}} +{{- if gt (len .AllTranslations) 1 }} +{{- range .AllTranslations }} + +{{- if eq .Language.Lang "en" }} + +{{- end }} +{{- end }} +{{- end }} + {{/* JSON-LD Organization (every page) */}}