From 3c70b6c1b42779db45a727f23f62f60e1e12c579 Mon Sep 17 00:00:00 2001 From: tym83 <6355522@gmail.com> Date: Fri, 24 Jul 2026 10:31:51 +0500 Subject: [PATCH 01/14] feat(i18n): guarantee link/identifier integrity and check typography Masking previously covered code, shortcodes and comments, so a URL, a bare CLI flag, a version number or a brand sitting in ordinary prose was defended by a prompt rule alone. Link destinations are now masked like any other protected span, and two deterministic checks feed the existing revise loop: - integrity_findings() compares versions, bare flags and do-not-translate terms between source and translation, catching a localized version separator or a transliterated brand. - check_typography() enforces the per-language rules the style guides state (Russian guillemets, German quotes, Spanish inverted marks, Chinese full-width punctuation, pt-PT vocabulary leaks, Devanagari digits). Both look at prose only; markup, code and link targets are exempt so the checks do not cry wolf on correct ASCII punctuation in an HTML attribute. lint_translations.py applies the same typography rules to already-published pages, where a hand edit is otherwise never re-checked. Co-Authored-By: Claude Signed-off-by: tym83 <6355522@gmail.com> --- hack/i18n/lib.py | 162 ++++++++++++++++++++++++++++++++- hack/i18n/lint_translations.py | 73 +++++++++++++++ hack/i18n/test_i18n.py | 84 +++++++++++++++++ hack/i18n/translate.py | 8 ++ 4 files changed, 322 insertions(+), 5 deletions(-) create mode 100755 hack/i18n/lint_translations.py diff --git a/hack/i18n/lib.py b/hack/i18n/lib.py index f936db3c..b30b1552 100644 --- a/hack/i18n/lib.py +++ b/hack/i18n/lib.py @@ -60,6 +60,18 @@ # link, the delimiters, the param names) is structure and stays verbatim. _VISIBLE_ATTR_RE = re.compile(r'\b(?:caption|alt|title)="([^"]*)"') +# Link DESTINATIONS. Only backticked code was structurally protected before, so a +# URL sitting in ordinary prose (`[text](https://…)`) was defended by a prompt rule +# alone — the model could silently mutate, localize or "fix" it and nothing would +# catch it. Masking the destination makes URL integrity a guarantee instead of a +# hope, while leaving the link TEXT exposed so it still gets translated. +# inline: [text](/docs/install "Optional title") -> destination only +# autolink: +# refdef: [id]: https://example.com +_LINKDEST_RE = re.compile(r'(?<=\])\((?P<[^>]*>|[^)\s]*)(?P\s+"[^"]*")?\)') +_AUTOLINK_RE = re.compile(r'<(?:https?|mailto):[^>\s]+>') +_REFDEF_RE = re.compile(r'(?m)^(?P<label>\[[^\]]+\]:[ \t]*)(?P<dest>\S+)') + def load_config() -> dict: with open(CONFIG_PATH, encoding="utf-8") as fh: @@ -463,11 +475,9 @@ def _shortcode(m: "re.Match") -> str: text = _HTMLCOMMENT_RE.sub(_sub("HTMLCOMMENT"), text) text = _INLINECODE_RE.sub(_sub("INLINECODE"), text) - # 4. Raw HTML tags, last so that a tag written inside backticks or a fence - # is already a placeholder and is not matched twice. Visible attribute - # values are exposed between placeholders exactly as they are for - # shortcodes: masking a tag wholesale would leave alt and title text in - # English on every localized page. + # 4. Raw HTML tags. Visible attribute values are exposed between placeholders + # exactly as they are for shortcodes: masking a tag wholesale would leave alt + # and title text in English on every localized page. def _htmltag(m: "re.Match") -> str: tag = m.group(0) out, pos = [], 0 @@ -478,6 +488,15 @@ def _htmltag(m: "re.Match") -> str: out.append(_stash(tag[pos:], "HTMLTAG")) return "".join(out) text = _HTMLTAG_RE.sub(_htmltag, text) + + # 5. Link destinations LAST, so URLs already inside code/shortcodes/HTML tags + # are covered by their own placeholders and are not double-masked. Link TEXT + # stays exposed and still gets translated; only the target is opaque. + text = _AUTOLINK_RE.sub(_sub("URL"), text) + text = _LINKDEST_RE.sub( + lambda m: "(" + _stash(m.group("dest"), "URL") + (m.group("title") or "") + ")", text) + text = _REFDEF_RE.sub( + lambda m: m.group("label") + _stash(m.group("dest"), "URL"), text) return text, store @@ -539,6 +558,139 @@ def has_ref_shortcode(text: str) -> bool: return bool(_ANY_REF_SHORTCODE.search(text)) +# ---- deterministic integrity & typography checks ---------------------------- +# +# The masking in protect() guarantees that code, shortcodes and URLs survive +# byte-for-byte. What it CANNOT guarantee is the material that legitimately sits +# in ordinary prose: a bare `--flag`, a `kind: Tenant` written without backticks, +# a version number, a brand from do_not_translate. Those are defended only by +# prompt rules, which is to say softly. These checks turn "the model was told +# not to" into "we looked". They return FINDINGS (fed to the same revise loop as +# the reviewers) rather than raising: a false positive should cost one revise +# round, never a hard-failed page. + +_CODEISH_RE = re.compile( + r"```.*?```" # fenced code + r"|`[^`\n]+`" # inline code + r"|§§[A-Z]+_\d+§§" # pipeline placeholders + r"|\{\{[<%].*?[%>]\}\}" # Hugo shortcodes (params are not prose) + r"|<!--.*?-->" # HTML comments + r"|<[^>\n]+>" # HTML tags with their attributes + r"|\]\([^)\s]*\)" # markdown link/image destinations + r"|^\[[^\]]+\]:[ \t]*\S+", # reference-link definitions + re.DOTALL | re.MULTILINE) + + +def _prose_only(text: str) -> str: + """Drop everything that is markup or code, so the checks see prose alone. + + Without this the typography rules fire on things that are *correctly* written + with ASCII punctuation — an HTML attribute (`class="hero"`), a shortcode + param, a file path — and a check that cries wolf is a check reviewers learn + to ignore. + """ + return _CODEISH_RE.sub(" ", text) + + +# Version-ish tokens: v1.5, 1.2.3, v1.2.5. Localizing the separator (1,2,3) or +# bumping a digit changes documented behaviour, so counts must match the source. +_VERSION_RE = re.compile(r"\bv?\d+\.\d+(?:\.\d+)?\b") +# Bare long CLI flags in prose. +_FLAG_RE = re.compile(r"(?<![\w-])--[a-z][a-z0-9-]{2,}\b") + + +def integrity_findings(src_body: str, tr_body: str, dnt_terms: list[str] | None = None, + who: str = "integrity-check") -> list[dict]: + """Compare source and translation for material that must survive verbatim. + + Checks, all on prose only (masked spans are already guaranteed): + * version tokens — same multiset + * bare CLI flags — same multiset + * do_not_translate — a term present in the source must not vanish + """ + out: list[dict] = [] + src, tr = _prose_only(src_body), _prose_only(tr_body) + + def _counts(rx, s): + d: dict[str, int] = {} + for m in rx.finditer(s): + d[m.group(0)] = d.get(m.group(0), 0) + 1 + return d + + for label, rx in (("version", _VERSION_RE), ("CLI flag", _FLAG_RE)): + a, b = _counts(rx, src), _counts(rx, tr) + for tok, n in a.items(): + got = b.get(tok, 0) + if got < n: + out.append({"severity": "major", "from": who, + "issue": f"{label} '{tok}' appears {n}× in the English source but " + f"{got}× in the translation — it must be carried over " + f"unchanged"}) + for tok in b: + if tok not in a: + out.append({"severity": "minor", "from": who, + "issue": f"{label} '{tok}' appears in the translation but not in " + f"the source — do not invent versions or flags"}) + + for term in (dnt_terms or []): + n = src.count(term) + if n and tr.count(term) < n: + out.append({"severity": "major", "from": who, + "issue": f"do-not-translate term '{term}' appears {n}× in the source " + f"but only {tr.count(term)}× in the translation — it must be " + f"kept verbatim, not translated or transliterated"}) + return out + + +# Per-language typography. The style guides state these rules; nothing checked +# them, so a page could read fine and still be typeset like English. Findings are +# advisory-but-real: they are the cheapest quality signal we have per language. +_TYPO_RULES: dict[str, list[tuple[str, str]]] = { + "ru": [ + (r'"[^"\n]{1,80}"', 'ASCII double quotes in Russian prose — use «ёлочки»'), + (r'[“”][^\n]{0,80}[“”]', 'English curly quotes in Russian prose — use «ёлочки»'), + # A hyphen doing a dash's job — but NOT a list marker ("\n- item"), which + # is why a non-space character is required immediately before it. + (r'(?<=[^\s])[ ]-[ ](?=\S)', 'hyphen used as a dash — use the em dash «—»'), + ], + "de": [ + (r'"[^"\n]{1,80}"', 'ASCII double quotes in German prose — use „…“'), + (r'[“][^\n]{0,80}[”]', 'English curly quotes in German prose — use „…“'), + ], + "es": [ + (r'(?<![¿])\b[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}\?', 'question without an opening ¿'), + (r'(?<![¡])\b[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}!', 'exclamation without an opening ¡'), + ], + "pt-br": [ + (r'\b(ficheiro|utilizador|ecrã|rato|autocarro|telemóvel|casa de banho)\b', + 'European Portuguese vocabulary leaked into pt-BR'), + (r'\bestá a [a-zçãéêó]+r\b', 'European Portuguese "está a fazer" — pt-BR uses the gerund'), + ], + "zh-cn": [ + (r'[一-鿿]\s*[,;:!?]', 'half-width punctuation after a Chinese character — use ,;:!?'), + (r'[,;:!?]\s*[一-鿿]', 'half-width punctuation before a Chinese character — use ,;:!?'), + (r'[一-鿿]\.(?=\s|$)', 'half-width period ending a Chinese sentence — use 。'), + (r'[A-Za-z0-9]', 'full-width Latin letters or digits — use half-width'), + ], + "hi": [ + (r'[०-९]', 'Devanagari digits — use Western Arabic numerals in technical content'), + ], +} + + +def check_typography(text: str, lang: str, who: str = "typography-check") -> list[dict]: + """Per-language typography findings (prose only; code spans are exempt).""" + prose = _prose_only(text) + out: list[dict] = [] + for pattern, message in _TYPO_RULES.get(lang, []): + hits = re.findall(pattern, prose) + if hits: + sample = hits[0] if isinstance(hits[0], str) else str(hits[0]) + out.append({"severity": "minor", "from": who, + "issue": f"{message} ({len(hits)}×, e.g. {sample.strip()[:60]!r})"}) + return out + + def deref_shortcodes(text: str, page_rel: str) -> str: """Rewrite Hugo ref/relref shortcodes to plain absolute links. diff --git a/hack/i18n/lint_translations.py b/hack/i18n/lint_translations.py new file mode 100755 index 00000000..c906ee0c --- /dev/null +++ b/hack/i18n/lint_translations.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Lint already-published translations for per-language typography. + +WHY: `check-i18n.sh` guards i18n key parity and translation freshness, and the +pipeline's gate checks a page at the moment it is generated. Neither looks at a +page again afterwards — so a hand edit (or a page translated before a style rule +existed) can sit in the tree indefinitely with English quotation marks in Russian +prose or half-width punctuation in Chinese. The style guides state these rules; +this makes them enforceable on every PR. + +Findings are advisory by default (exit 0) because typography rules are heuristic +and the tree has history. Pass --strict to fail the build instead, once a +language's backlog is clean. + +Usage: + python3 hack/i18n/lint_translations.py # all configured languages + python3 hack/i18n/lint_translations.py --lang ru # one language + python3 hack/i18n/lint_translations.py --strict # exit 1 on any finding +""" +from __future__ import annotations + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import lib + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--lang", help="only this language code") + ap.add_argument("--strict", action="store_true", + help="exit non-zero when findings exist (default: advisory)") + args = ap.parse_args() + + cfg = lib.load_config() + langs = [l["code"] for l in cfg["languages"] if args.lang in (None, l["code"])] + if args.lang and not langs: + print(f"::error::unknown --lang '{args.lang}'", file=sys.stderr) + return 2 + + total = 0 + for lang in langs: + root = os.path.join(lib.REPO_ROOT, cfg["content_dir"], lang) + if not os.path.isdir(root): + continue + for dirpath, _dirs, files in os.walk(root): + for name in sorted(files): + if not name.endswith((".md", ".html")): + continue + path = os.path.join(dirpath, name) + with open(path, encoding="utf-8") as fh: + _fm, body, _raw = lib.split_frontmatter(fh.read()) + findings = lib.check_typography(body, lang) + if findings: + rel = os.path.relpath(path, lib.REPO_ROOT) + print(f"{rel}") + for f in findings: + print(f" - {f['issue']}") + total += len(findings) + + if total: + print(f"\n{total} typography finding(s). These are the rules the per-language " + f"style guides state; fix them or refine the rule in lib._TYPO_RULES.") + else: + print("no typography findings") + return 1 if (total and args.strict) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hack/i18n/test_i18n.py b/hack/i18n/test_i18n.py index 438af01e..4f9b78b2 100644 --- a/hack/i18n/test_i18n.py +++ b/hack/i18n/test_i18n.py @@ -805,5 +805,89 @@ def test_empty_worklist_still_reports_a_transcreated_page(self): os.unlink(translate.REPORT_PATH) +class TestLinkDestinationMasking(unittest.TestCase): + """URLs used to be defended by a prompt rule only. They are masked now, so a + model that "fixes" or localizes a link cannot silently ship a broken one.""" + + def test_inline_link_destination_is_masked_but_text_is_not(self): + masked, store = lib.protect("See the [install guide](/docs/v1.5/install) for details.") + self.assertNotIn("/docs/v1.5/install", masked) + self.assertIn("install guide", masked) # link text stays translatable + self.assertEqual(lib.restore(masked, store), + "See the [install guide](/docs/v1.5/install) for details.") + + def test_autolink_and_refdef_are_masked(self): + text = "Visit <https://cozystack.io> now.\n\n[ref]: https://example.com/a?b=c\n" + masked, store = lib.protect(text) + self.assertNotIn("cozystack.io", masked) + self.assertNotIn("example.com", masked) + self.assertEqual(lib.restore(masked, store), text) + + def test_link_title_is_left_translatable(self): + masked, store = lib.protect('A [link](/a/b "Read this") here.') + self.assertIn('"Read this"', masked) + self.assertNotIn("/a/b", masked) + self.assertEqual(lib.restore(masked, store), 'A [link](/a/b "Read this") here.') + + +class TestIntegrityFindings(unittest.TestCase): + def test_clean_translation_has_no_findings(self): + self.assertEqual( + lib.integrity_findings("Cozystack v1.5 uses --dry-run.", + "Cozystack v1.5 verwendet --dry-run.", ["Cozystack"]), []) + + def test_dropped_version_is_major(self): + f = lib.integrity_findings("Upgrade to v1.5 now.", "Jetzt aktualisieren.") + self.assertTrue(any(x["severity"] == "major" and "v1.5" in x["issue"] for x in f)) + + def test_localized_decimal_in_a_version_is_caught(self): + # "v1.5" rewritten as "v1,5" — the token no longer matches the source. + f = lib.integrity_findings("Use v1.5.", "Nutze v1,5.") + self.assertTrue(any("v1.5" in x["issue"] for x in f)) + + def test_dropped_flag_is_major(self): + f = lib.integrity_findings("Pass --dry-run to preview.", "Zum Testen übergeben.") + self.assertTrue(any(x["severity"] == "major" and "--dry-run" in x["issue"] for x in f)) + + def test_translated_brand_is_caught(self): + f = lib.integrity_findings("Cozystack is a platform.", "Козистек — это платформа.", + ["Cozystack"]) + self.assertTrue(any("Cozystack" in x["issue"] for x in f)) + + def test_code_spans_are_exempt(self): + # A version that only lives inside code is already guaranteed by masking; + # it must not be double-reported here. + self.assertEqual(lib.integrity_findings("Run `helm install v1.5`.", "Führen Sie aus."), []) + + +class TestTypographyChecks(unittest.TestCase): + def test_russian_ascii_quotes_flagged(self): + f = lib.check_typography('Это "кластер" здесь.', "ru") + self.assertTrue(any("ёлочки" in x["issue"] for x in f)) + + def test_russian_guillemets_pass(self): + self.assertEqual(lib.check_typography("Это «кластер» здесь.", "ru"), []) + + def test_chinese_halfwidth_punctuation_flagged(self): + self.assertTrue(lib.check_typography("这是集群, 然后部署", "zh-cn")) + + def test_chinese_fullwidth_punctuation_passes(self): + self.assertEqual(lib.check_typography("这是集群,然后部署。", "zh-cn"), []) + + def test_pt_pt_vocabulary_leak_flagged(self): + f = lib.check_typography("Abra o ficheiro de configuração.", "pt-br") + self.assertTrue(any("European Portuguese" in x["issue"] for x in f)) + + def test_devanagari_digits_flagged(self): + self.assertTrue(lib.check_typography("क्लस्टर में ३ नोड हैं।", "hi")) + + def test_code_spans_are_exempt_from_typography(self): + # Straight quotes inside code are correct and must not be flagged. + self.assertEqual(lib.check_typography('Запустите `echo "hi"` сейчас.', "ru"), []) + + def test_unknown_language_is_a_no_op(self): + self.assertEqual(lib.check_typography('Anything "here".', "xx"), []) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/hack/i18n/translate.py b/hack/i18n/translate.py index 62376c33..bce32152 100644 --- a/hack/i18n/translate.py +++ b/hack/i18n/translate.py @@ -301,6 +301,14 @@ def translate_page(cfg, glossary, lang_cfg, rel) -> tuple[str, bool, list[dict]] or [{"severity": "major", "from": "back-translation", "issue": "revise verdict with no findings listed"}]) + # 2b. deterministic checks — cheap, objective, and not subject to the + # reviewers' mood. Masking already guarantees code/shortcodes/URLs; these + # cover what legitimately lives in prose (versions, bare flags, brands) + # and the per-language typography the style guides mandate but nothing + # previously verified. They feed the same revise loop as the reviewers. + findings += lib.integrity_findings(body, tr_body, glossary.get("do_not_translate")) + findings += lib.check_typography(tr_body, lang_cfg["code"]) + # 3. two native reviewers for reviewer in cfg["review"]["reviewers"]: sys_r = render(_read(os.path.join(PROMPT_DIR, os.path.basename(reviewer["prompt"]))), From ab06fd731ad6a62f7924518acb79bdf881b0f8f1 Mon Sep 17 00:00:00 2001 From: tym83 <6355522@gmail.com> Date: Fri, 24 Jul 2026 10:31:51 +0500 Subject: [PATCH 02/14] docs(i18n): turn the per-language style guides into real contracts Each guide was 3-6 lines, yet the whole fluency and typography strategy rests on injecting them into the translate and both reviewer prompts. They are now 80-100 lines each and work as an instruction set and a review rubric: register and address form, heading conventions, a decision rule for terms outside the glossary, typography, number/date formatting, the grammar traps of translating from English into that language, calque patterns with fixes, false friends, and a reviewer checklist of the MT failure modes specific to the language. Every original decision is preserved; the guides expand around them. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com> --- hack/i18n/style-guides/de.md | 106 ++++++++++++++++++++++++++++++-- hack/i18n/style-guides/es.md | 94 ++++++++++++++++++++++++++-- hack/i18n/style-guides/hi.md | 103 ++++++++++++++++++++++++++++++- hack/i18n/style-guides/pt-br.md | 85 +++++++++++++++++++++++-- hack/i18n/style-guides/ru.md | 101 ++++++++++++++++++++++++++++-- hack/i18n/style-guides/zh-cn.md | 91 +++++++++++++++++++++++++++ 6 files changed, 557 insertions(+), 23 deletions(-) diff --git a/hack/i18n/style-guides/de.md b/hack/i18n/style-guides/de.md index 48dd72dc..f52c7b88 100644 --- a/hack/i18n/style-guides/de.md +++ b/hack/i18n/style-guides/de.md @@ -1,5 +1,101 @@ -- 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". +Code blocks, inline code, Hugo shortcodes, URLs, front-matter keys and `§§NAME_N§§` placeholders are masked structurally by the pipeline — never translate, reorder or "fix" anything inside them. Everything below applies to prose only. + +## 1. Register and tone + +- Formal register: address the reader with **„Sie“** — docs, blog, landing pages, UI strings, notes and warnings alike. Never „du“, never a register switch inside a page. +- Docs: precise, neutral, instructional. Blog/landing: same „Sie“, but shorter sentences, active voice, and transcreated marketing claims instead of word-for-word renderings. +- Instructions use the Sie-imperative (verb first): "Create a namespace." → ✗ „Du erstellst einen Namespace.“ / ✗ „Erstellen ein Namespace.“ → ✓ „Erstellen Sie einen Namespace.“ +- In numbered step lists the Infinitiv is acceptable („Cluster anlegen“), but never mix Infinitiv and Sie-Imperativ within one list. +- "Please note" → ✓ „Beachten Sie“ (not „Bitte beachten Sie bitte“). "Let's create…" → ✓ „Erstellen Sie …“ / „Als Nächstes …“, never „Lassen Sie uns …“. + +## 2. Headings + +- German capitalization: nouns and the first word up, everything else down. English title case must not survive: "Installing the Cozystack Platform" → ✗ „Installieren Der Cozystack Plattform“ → ✓ „Cozystack-Plattform installieren“. +- Task headings: Infinitivkonstruktion. "Configure networking" → ✓ „Netzwerk konfigurieren“ (not „Konfigurieren Sie das Netzwerk“). Concept headings: nominal — "Storage architecture" → ✓ „Speicherarchitektur“. +- Idiomatic, not literal: "Getting started" → ✓ „Erste Schritte“; "Troubleshooting" → ✓ „Fehlerbehebung“; "Prerequisites" → ✓ „Voraussetzungen“; "What's next" → ✓ „Wie geht es weiter“. +- No trailing period; keep question headings as questions; keep heading levels exactly as in the source. + +## 3. Terminology, API objects, gender, plural + +Decision rule for a term **not** covered by the injected glossary, applied in this order: +1. Is it an identifier — API kind, CRD, field, flag, command, chart or package name? → keep verbatim English, uninflected internally, never translated. +2. Does kubernetes.io/de use an established German rendering? → use it. +3. Does the CNCF Cloud Native Glossary (de) have one? → use it. +4. Would a German platform engineer say the English word out loud (Deployment, Workload, Pod, Container, Ingress, Backup, Image)? → keep the English loanword. +5. Otherwise translate, and gloss once on first use: „Mandantentrennung (multi-tenancy)“. Never render the same term two ways on one page. + +- API objects stay English and capitalized in prose: „ein Pod“, „das Deployment“, „ein Secret“, „die VirtualMachine“. Do not translate an object kind (✗ „Geheimnis“ for `Secret`); use „Knoten“ for a cluster node as a general concept, `Node` when the API object is meant. +- Gender (a wrong-but-consistent article is a minor issue; a fluctuating one is major): **der** Cluster, der Pod, der Node, der Container, der Namespace, der Service, der Controller, der Ingress, der Server, der Speicher. **das** Deployment, das Secret, das Volume, das Image, das Manifest, das Backup — plus every `-ment` and every `-ing` noun (das Logging, das Monitoring, das Networking, das Scheduling). **die** Workload, die Ressource, die Instanz, die Pipeline. +- Acronyms take the gender of the German head noun: die API (Schnittstelle), die CRD (Definition), die CPU (Einheit), der RAM (Speicher), die IP-Adresse, das YAML (Format). +- Plural: English `-s` for most loanwords (die Pods, die Nodes, die Deployments, die Volumes, die Namespaces); **no** `-s` for `-er`/`-el` stems (der Cluster → die Cluster, der Container → die Container, der Server → die Server). Genitive singular takes `-s`: „des Pods“, „des Clusters“, „des Deployments“. +- Separate act from object: "the deployment of the app" → „die Bereitstellung der Anwendung“; the `Deployment` object → „das Deployment“. "to deploy" → „bereitstellen“/„ausrollen“, not „deployen“. + +## 4. Compound nouns (the biggest German MT failure mode) + +- All-German compounds are written as one word: ✗ „Speicher Klasse“ → ✓ „Speicherklasse“; ✗ „Netzwerk Richtlinie“ → ✓ „Netzwerkrichtlinie“; ✗ „Konfigurations Datei“ → ✓ „Konfigurationsdatei“. A space between the parts of a compound (Deppenleerzeichen) is always an error. +- Mixed English-German compounds are hyphenated at every joint: ✓ „Kubernetes-Cluster“, „Pod-Netzwerk“, „Container-Image“, „Cluster-Konfiguration“, „Node-Ausfall“, „Speicher-Backend“. +- Multiword English terms used as one noun get Durchkopplung (all joints hyphenated): ✗ „Control Plane Knoten“ → ✓ „Control-Plane-Knoten“; ✗ „Bare Metal Server“ → ✓ „Bare-Metal-Server“; ✓ „High-Availability-Setup“, „Multi-Tenant-Umgebung“, „Kubernetes-as-a-Service-Angebot“. +- Identifiers keep their exact original spelling inside a compound: „YAML-Datei“, „kubectl-Befehl“, „CSI-Treiber“, „VirtualMachine-Ressource“, „Helm-Chart“. +- Readability beats mechanical joining: with three or more elements, hyphenate the load-bearing joint or reformulate. ✗ „Kubernetesclusterkonfigurationsdatei“ → ✓ „Konfigurationsdatei des Kubernetes-Clusters“. +- Do not hyphenate a plain German compound that should be closed (✗ „Speicher-Klasse“ where „Speicherklasse“ exists), and never lowercase the resulting noun: ✓ „das Cluster-Upgrade“. + +## 5. Typography and punctuation + +- Quotation marks: „…“ (U+201E … U+201C); nested ‚…‘ (U+201A … U+2018). Never straight `"`, never English “…”. Do not put quotes around code — it is masked. +- Gedankenstrich: en dash **–** with spaces around it; never the em dash —. "Cozystack — a platform for…" → ✓ „Cozystack – eine Plattform für …“. Ranges use – without spaces: „3–5 Nodes“. The hyphen `-` is reserved for compounds. +- Ellipsis: the single character „…“, with a preceding space when it stands for omitted words. +- No Oxford comma: "A, B, and C" → ✓ „A, B und C“. +- Commas German requires and English does not: before every subordinate clause (dass, weil, wenn, ob, obwohl, relative clauses) and before extended infinitive groups with um/ohne/statt … zu. "We recommend that you upgrade first." → ✓ „Wir empfehlen, dass Sie zuerst aktualisieren.“; ✓ „…, um den Cluster zu aktualisieren.“ +- Lists: sentence fragments take no final period, full sentences do — consistently within one list. After a colon, continue lowercase unless a full sentence or a noun follows. + +## 6. Numbers, dates, units + +- Decimal **comma** in prose: "3.14" → ✓ „3,14“; "0.5 vCPU" → ✓ „0,5 vCPU“. CRITICAL: never convert inside code, version numbers (v1.5.0), image tags, IP addresses/CIDRs, ports, CLI output or YAML — those keep the source form byte-for-byte. +- Thousands separator: period, or a narrow space: "10,000 pods" → ✓ „10.000 Pods“. Prose only, same exception list as above. +- Dates: "07/24/2026" → ✓ „24.07.2026“ or „24. Juli 2026“. Never keep US month/day order. ISO dates in front matter and code stay ISO. Times: "3 PM" → ✓ „15:00 Uhr“. +- Units: space between number and unit, percent included — „4 GB“, „10 Gbit/s“, „50 %“, „25 °C“ (non-breaking space preferred). Kubernetes quantities (512Mi, 2000m) are code-like and stay unchanged. +- Ordinals take a period: „im 3. Schritt“. "billion" → „Milliarde“ (never „Billion“). + +## 7. Grammar traps when translating from English + +- Verbzweitstellung: the finite verb is the second element of a main clause. ✗ „Mit diesem Befehl Sie erstellen einen Cluster.“ → ✓ „Mit diesem Befehl erstellen Sie einen Cluster.“ +- Verbendstellung in subordinate clauses. ✗ „…, weil der Cluster hat nicht genug Speicher.“ → ✓ „…, weil der Cluster nicht genug Speicher hat.“ +- Modal verbs push the infinitive to the end. ✗ „Sie können erstellen eine virtuelle Maschine.“ → ✓ „Sie können eine virtuelle Maschine erstellen.“ +- Passive: prefer active or „lässt sich“; avoid stacked „wird … werden“ chains. "The cluster can be upgraded without downtime." → ✓ „Der Cluster lässt sich ohne Ausfallzeit aktualisieren.“ For a generic actor use the passive or man-Konstruktion, not „Sie“: "Backups are stored in S3." → ✓ „Backups werden in S3 abgelegt.“ +- Capability, not permission: "Cozystack allows you to run VMs" → ✗ „Cozystack erlaubt Ihnen, VMs auszuführen“ → ✓ „Mit Cozystack können Sie VMs betreiben“ / „Cozystack ermöglicht den Betrieb von VMs“. +- Gerunds are not present participles: "Using kubectl, you can…" → ✗ „Benutzend kubectl …“ → ✓ „Mit kubectl können Sie …“; "After installing…" → ✓ „Nach der Installation …“. +- Genitiv over „von“ in docs: ✓ „die Konfiguration des Clusters“; „von“ only without article or with bare plurals: ✓ „eine Liste von Nodes“. +- Restore articles English drops: "Create Deployment" → ✓ „Erstellen Sie ein Deployment“. Watch case after prepositions: „mit dem Cluster“, „für den Node“, „in der Umgebung“. +- No English noun stacking: ✗ „Cozystack Kubernetes Plattform“ → ✓ „die Kubernetes-Plattform Cozystack“. + +## 8. Translationese and calques to eliminate + +- ✗ „In diesem Artikel werden wir …“ → ✓ „Dieser Artikel zeigt, wie …“ / „Im Folgenden …“. +- ✗ filler „einfach“/„simpel“ ("simply run") → ✓ delete it: „Führen Sie … aus.“ +- ✗ „erlaubt Ihnen zu …“ → ✓ „ermöglicht …“ / „damit können Sie …“. +- ✗ dangling „basierend auf“ at the start of a clause → ✓ „auf Basis von“ / „anhand von“. +- ✗ „in Ordnung sein“ for "to be fine/OK" → ✓ „funktioniert“ / „ist zulässig“; ✗ „Sie wollen vielleicht …“ (you may want to) → ✓ „Optional können Sie …“. +- ✗ „Stellen Sie sicher, dass Sie X getan haben“ → ✓ „Vergewissern Sie sich, dass X …“ / „Voraussetzung: …“. +- ✗ „unterstützt“ for every sense of "support": feature support ✓ „unterstützt“, commercial support ✓ „Support“/„Betreuung“, "supported version" ✓ „unterstützte Version“. +- ✗ „adressieren“ (to address an issue) → ✓ „beheben“/„angehen“; ✗ „realisieren“ (to realize) → ✓ „umsetzen“ or „feststellen“. +- ✗ „Einmal der Cluster läuft, …“ → ✓ „Sobald der Cluster läuft, …“; ✗ over-nominalization „die Durchführung der Installation“ → ✓ „die Installation“; ✗ „Technologien wie z. B. …“ → ✓ „Technologien wie …“. + +## 9. False friends (EN ≠ DE) + +eventually ≠ eventuell → „schließlich/letztendlich“ (eventual consistency → „letztendliche Konsistenz“); actual/actually ≠ aktuell → „tatsächlich“ (current → „aktuell“); to control ≠ kontrollieren → „steuern/regeln“ (kontrollieren = to check); to become ≠ bekommen → „werden“; sensible ≠ sensibel → „sinnvoll/vernünftig“ (sensitive data → „sensible Daten“ ✓); provision ≠ Provision (= commission) → „bereitstellen/Bereitstellung“; note ≠ Note (= grade) → „Hinweis/Anmerkung“; billion ≠ Billion → „Milliarde“; consequent ≠ konsequent → „nachfolgend“; to handle ≠ handeln → „verarbeiten/behandeln“; map ≠ Mappe → „Zuordnung/Abbildung“; to spend ≠ spenden → „ausgeben“; brave ≠ brav → „mutig“; gift ≠ Gift (= poison) → „Geschenk“; chef ≠ Chef (= boss) → „Koch“. + +## 10. Reviewer checklist — German MT failure modes + +1. „du“/„dein“ anywhere, or a register switch mid-page. +2. English title case in headings, or a heading rendered as a full Sie-sentence where an Infinitiv belongs. +3. Deppenleerzeichen and missing Durchkopplung („Bare Metal Server“, „Control Plane Knoten“, „Kubernetes Cluster“). +4. Wrong or fluctuating gender for the same loanword; wrong plural („die Clusters“, „die Containers“); missing genitive `-s`. +5. Decimal points left in prose, or decimal commas wrongly injected into versions, IPs, image tags or code. +6. Em dash — or straight `"` instead of – and „…“. +7. Missing comma before dass/weil/wenn/relative clauses or before an extended infinitive group. +8. English word order surviving: verb not in second position, subordinate-clause verb not final, infinitive not at the end after a modal. +9. Untranslated leftovers — or the opposite: translated API kinds, flags, field names, or do_not_translate terms. +10. One source term rendered several ways on the same page; a glossary term overridden by an improvised synonym. +11. Calques from §8, especially „erlaubt Ihnen zu“, „in diesem Artikel werden wir“, filler „einfach“. +12. False friends from §9 — check every occurrence of „eventuell“, „aktuell“, „kontrollieren“, „sensibel“. +13. Sentences over ~25 words with several nested subordinate clauses: split them. German technical prose tolerates them worse than the English source suggests. diff --git a/hack/i18n/style-guides/es.md b/hack/i18n/style-guides/es.md index 1eb8d74f..1b2df25d 100644 --- a/hack/i18n/style-guides/es.md +++ b/hack/i18n/style-guides/es.md @@ -1,5 +1,89 @@ -- 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. +Applies to both writing and reviewing: every rule below is also a review checkpoint. Code blocks, inline `code`, Hugo shortcodes, URLs, file paths and front-matter keys are masked by the pipeline (`§§NAME_N§§`) — never translate, reorder or "fix" them; the punctuation and number rules below apply to PROSE ONLY. + +## 1. Register and variety + +- Neutral international Spanish, usable in Spain and Latin America: no region-specific slang, no `vosotros`, no `vos`, and avoid the `ordenador`/`computadora` split (say `equipo`, `servidor`, `nodo`). +- Address the reader with **tú**, consistently on every page. Imperatives in `tú`: ✓ `Ejecuta`, `Crea`, `Consulta` — ✗ `Ejecute`, `Realicen`. A single `Consulte la guía` inside a `tú` page is a major finding. +- Docs: precise, imperative, short sentences, no first person except `En esta guía…`. Blog/landing: may transcreate, may use `nosotros` for the Cozystack team (`hemos publicado`), rhetorical questions allowed. +- Drop English politeness padding: `Please run…` → ✓ `Ejecuta…` — ✗ `Por favor, ejecuta…`. +- Gender-neutral wording by rephrasing (`el equipo`, `quienes administran el clúster`), never `@`, `x`, `e` or `usuarios/as`. + +## 2. Headings and capitalization — CRITICAL + +- Spanish uses **sentence case**, never English title case. `Getting Started with Cozystack` → ✓ `Primeros pasos con Cozystack` — ✗ `Primeros Pasos Con Cozystack`. +- Only the first word plus proper nouns, brands and API kinds are capitalized: ✓ `Cómo funciona el almacenamiento en LINSTOR`. +- `-ing` headings become a noun or an infinitive, as on kubernetes.io/es: `Installing Cozystack` → ✓ `Instalación de Cozystack` / `Instalar Cozystack` — ✗ `Instalando Cozystack`. Keep one pattern per page. +- Question headings take both marks: `How does it work?` → ✓ `¿Cómo funciona?`. +- No capital after a colon: ✓ `Nota: el clúster debe estar en ejecución.` — ✗ `Nota: El clúster…`. Capitalize after a colon only before a full quotation. +- Lowercase months, days, languages and nationalities: ✓ `el lunes`, `julio`, `en inglés`. +- Table headers and UI labels also follow sentence case. Do not drop the article for headline-ese: ✓ `Instalar el clúster` — ✗ `Instalar clúster`. + +## 3. Terminology policy (a do_not_translate + preferred list is injected separately — apply it first) + +Decision rule for a term in NEITHER list, in this order: +1. Is it an identifier the reader must match in a manifest, CLI or UI (`kubectl`, `namespace` inside a command, flags, field names, API kinds)? → keep verbatim, unchanged. +2. Does kubernetes.io/es or the CNCF Cloud Native Glossary already have a Spanish rendering? → use it. +3. Is there a natural, unambiguous Spanish word an engineer actually says? → translate (`rendimiento`, `red`, `almacenamiento`, `copia de seguridad`, `registros`). +4. Otherwise keep the English term bare and gloss it once, on first use: ✓ `sidecar (contenedor auxiliar)`, then `sidecar` alone. Never invent a translation nobody uses. +- Pick one rendering per term and keep it identical across the whole page. +- **API kinds in prose** keep the English name, the capital and no accent: ✓ `el Pod`, `el Deployment`, `los Secrets`, `el Node`, `el Tenant`. The same word as an ordinary concept is lowercase and Spanish: ✓ `un despliegue continuo`, `los nodos del clúster`, `el inquilino (tenant)`. Never inflect a kept term: ✗ `Deploymentos`, ✗ `kuberneteses`, ✗ `el Helm's chart`. +- **Gender** = gender of the implicit Spanish hypernym: el pod, el clúster, el nodo, el contenedor, el chart, el endpoint, el backend, el volumen, el despliegue, el almacenamiento, el espacio de nombres; la imagen, la red, la carga de trabajo, la API, la caché, la instancia, la máquina virtual. Agreement must follow: ✓ `una imagen firmada`, `los pods pendientes` — ✗ `la pod`, `el imagen`, `los clúster`. +- **Plurals**: adapted words take Spanish plurals (`clústeres`, `contenedores`, `servidores`); bare loanwords take `-s` (`los pods`, `los charts`, `los endpoints`). Acronyms are invariable: `las API`, `los CRD`. +- **Accent only when the adaptation is established** (RAE plus real ecosystem use): ✓ `clúster`, `búfer`, `caché`, `módulo`. Otherwise leave the loanword bare: ✓ `pod`, `chart`, `sidecar` — ✗ `pód`, ✗ `sídecar`. Do not italicize ecosystem loanwords. + +## 4. Typography and punctuation + +- Inverted marks are mandatory and go **where the question or exclamation actually starts**, not at the start of the sentence: ✓ `Si el nodo falla, ¿qué pasa con los datos?`, ✓ `Listo, ¡ya tienes un clúster!` — ✗ `¿Si el nodo falla, qué pasa con los datos?`, ✗ `Qué pasa con los datos?`. +- Quotes: use **"comillas inglesas"** (straight double quotes), not «comillas latinas». Rationale: kubernetes.io/es and the wider Latin American tech web use them, `«»` reads as Spain-specific and is inconsistent with the English source and with Markdown tooling. Nest as `"… 'anidado' …"`. Prefer **bold** over quotes for UI labels. +- **Raya (—)** for parentheticals, with a space outside and none inside: ✓ `El clúster —ya en ejecución— acepta cargas nuevas.` Never use `-` or `–` for this, and never leave a stray closing raya. In blog dialogue the raya opens the line with no space after it: `—Empezamos con tres nodos.` +- **No Oxford comma**: Spanish takes no comma before the `y`/`o` that closes a list. ✓ `nodos, discos y redes` — ✗ `nodos, discos, y redes`. (Exception only to prevent a real ambiguity.) +- Comma before an adversative or after a fronted subordinate clause: ✓ `Si el pod no arranca, revisa los eventos.`, ✓ `Funciona, pero requiere más memoria.` Never between subject and verb: ✗ `El operador de almacenamiento, crea el volumen.` +- Suspension points are always three, followed by a space. Use `:` rather than `;` to introduce lists and code blocks. + +## 5. Numbers, dates, units + +- **Decimal comma in prose**: `3.14 seconds` → ✓ `3,14 segundos` — ✗ `3.14 segundos`. NEVER convert inside version numbers, code, CLI output, YAML values, file names or CIDR notation: ✓ `Cozystack v1.5.0`, `10.0.0.1/24`, `cpu: 0.5`. +- Thousands: never a comma. `10,000 pods` → ✓ `10 000 pods` (non-breaking space) or `10000`; four digits stay unseparated (`1024`). +- Dates: `July 24, 2026` → ✓ `24 de julio de 2026` (lowercase month, `de` twice); numeric form `24/07/2026`. Never `Julio 24, 2026`. Front-matter `date:` values are masked — leave them. +- Space between number and unit, and before `%`: ✓ `16 GB`, `4 vCPU`, `500 ms`, `25 %`. Keep unit symbols in English/SI (`GB`, `TB`, `GiB`, `ms`). +- Abbreviations follow Spanish forms: `1.º`, `n.º`, `EE. UU.`. Spell out `segundos`, `minutos` in prose unless quoting a metric. + +## 6. Grammar traps when translating from English + +- **Gerund** — the top calque. Never gerund-as-adjective or gerund-of-consequence: ✗ `un archivo conteniendo la configuración` → ✓ `un archivo que contiene la configuración`; ✗ `El nodo falló, provocando un reinicio` → ✓ `El nodo falló y provocó un reinicio`. `Using X, you can…` → ✓ `Con X puedes…`. +- **Passive → pasiva refleja**, which Spanish strongly prefers: ✗ `El clúster es creado por el operador` → ✓ `El operador crea el clúster` / `El clúster se crea automáticamente`; ✗ `Los datos son almacenados en…` → ✓ `Los datos se almacenan en…`. +- **`you can`**: `puedes` when it is a real option, `es posible` in neutral docs — and often just drop it: `You can find the logs in /var/log` → ✓ `Los registros están en /var/log`. +- **Possessives → definite article**: `your cluster` → ✓ `el clúster` (`tu clúster` only when contrasting with someone else's); `Deploy your application` → ✓ `Despliega la aplicación`; `its configuration` → ✓ `la configuración`. +- **Noun stacking**: never a chain of four `de`. `Kubernetes cluster node pool autoscaling configuration` → ✗ `configuración de autoescalado de grupos de nodos de clúster de Kubernetes` → ✓ `configuración del autoescalado de los grupos de nodos en Kubernetes`. Break chains with an adjective (`red virtual`, `disco local`), a preposition (`en`, `para`) or a relative clause. Two consecutive `de` maximum. +- **ser vs estar**: identity or definition → `ser` (`Cozystack es una plataforma`); state, result, availability, location → `estar` (`el pod está listo`, `el servicio está disponible`, `el clúster está en ejecución`). ✗ `el pod es listo`. +- **Subjunctive** after `cuando` with future reference and after `asegúrate de que`: ✓ `Cuando el pod esté listo, ejecuta…` — ✗ `Cuando el pod está listo…`; ✓ `Asegúrate de que el nodo tenga suficiente memoria.` But `si` + present takes the indicative: ✓ `Si quieres aislar la carga…` — ✗ `Si quieras…`. +- Avoid the bureaucratic anaphoric `el mismo/los mismos`: ✗ `Descarga el chart y aplica el mismo` → ✓ `Descarga el chart y aplícalo`. + +## 7. Translationese and calques to fix + +- Filler openers: ✗ `En este artículo vamos a ver cómo…` → ✓ `Esta guía explica cómo…`, or start straight with the content. Delete `simplemente`, `básicamente`, `de hecho`, `es importante notar que` unless the English carries real emphasis. +- `support` → ✓ `admitir`, `ser compatible con`, `permitir` — ✗ `soportar` (that means "to endure"). `supported versions` → ✓ `versiones compatibles`; `unsupported` → ✓ `no compatible`. +- `apply`: ✓ `aplicar un manifiesto`, ✓ `esto se aplica a…`; but `apply a role` → ✓ `asignar un rol` and `apply for` → ✓ `solicitar` — ✗ `aplicar para`. +- `remove` → ✓ `eliminar`, `quitar` — ✗ `remover` (= to stir). `encrypt` → ✓ `cifrar`, `el cifrado` — ✗ `encriptar`. `library` → ✓ `biblioteca` — ✗ `librería`. `customize` → ✓ `personalizar` — ✗ `customizar`. +- Others: `deprecated` → `obsoleto`/`en desuso`; `performance` → `rendimiento`; `issue` → `problema`/`incidencia`; `feature` → `funcionalidad`; `release` → `versión`/`lanzamiento`; `setup` (n.) → `configuración`; `reset` → `restablecer`; `access` (v.) → `acceder a` (✗ `accesar`); `assume` → `suponer` (✗ `asumir`); `in order to` → `para` (✗ `en orden de`); `make sense` → `tener sentido` (✗ `hacer sentido`); `based on` → `según`/`a partir de` when `basado en` piles up; `additionally` → `además`. +- No English-style hyphenated compounds: ✗ `alta-disponibilidad`, ✗ `multi-inquilino` → ✓ `alta disponibilidad`, `multiinquilino`. No English possessive `'s`. +- Link text: never `haz clic aquí` → ✓ `Consulta la guía de instalación`. + +## 8. False friends (tech and business) + +`actually` → `en realidad`/`de hecho` (✗ `actualmente` = currently) · `eventually` → `con el tiempo`/`finalmente` (✗ `eventualmente` = occasionally) · `library` → `biblioteca` · `support` → `admitir` · `realize` → `darse cuenta` (✗ `realizar` = to carry out) · `sensible` → `sensato`/`razonable` (but `sensitive data` → `datos confidenciales`) · `assist` → `ayudar` (✗ `asistir` = to attend) · `consistent` → `coherente`/`uniforme` (✗ `consistente` = thick, solid) · `resume` → `reanudar` (✗ `resumir` = to summarize) · `topic` → `tema` (✗ `tópico` = cliché) · `large` → `grande` (✗ `largo` = long) · `ultimately` → `en última instancia` (✗ `últimamente` = lately) · `commodity hardware` → `hardware estándar` · `abort` → `cancelar`/`interrumpir` · `exit` → `salir` (✗ `éxito`) · `discrete` → `discreto` vs `discreet` → `prudente`. `argument`, `instance` and `policy` are fine as `argumento`, `instancia`, `política` in technical senses. + +## 9. Reviewer checklist — frequent MT failures in Spanish + +1. English Title Case leaking into headings, table headers or bold labels. +2. Missing opening `¿`/`¡`, or the mark placed at the sentence start instead of where the question begins. +3. Decimal point left as `.` in prose — or, worse, a decimal comma injected into a version, IP, CIDR or YAML value. +4. `usted`/`vosotros` drift, or mixed imperatives (`ejecuta` … `ejecute`) on one page. +5. Agreement drift around loanwords: `la pod`, `el imagen`, `los clúster`, `las nodos`. +6. Gerund calques (`conteniendo`, `permitiendo`, `resultando en`) and English passives left as `es/son` + participle. +7. `soportar`, `librería`, `encriptar`, `remover`, `customizar`, `accesar`, `en orden de`. +8. Missing article (`Instalar clúster`) or `de`-chains of four or more. +9. Capital after a colon; capitalized months or days; `Por favor` retained. +10. Terminology inconsistency: one English term rendered two ways on the same page, or a glossary/do_not_translate term inflected or translated. +11. Placeholders `§§NAME_N§§` lost, duplicated or reordered; code, URLs or YAML keys translated. +12. English clause order preserved in over-long sentences, comma splices, `el cual`/`el mismo` overuse, literal `aquí` link text. diff --git a/hack/i18n/style-guides/hi.md b/hack/i18n/style-guides/hi.md index 0ffcc103..93de8db1 100644 --- a/hack/i18n/style-guides/hi.md +++ b/hack/i18n/style-guides/hi.md @@ -1,3 +1,100 @@ -- 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. +# Hindi (hi) — style guide + +Modern standard Hindi (Devanagari) as written by Indian tech authors. Hinglish is normal and expected: keep widely-used English technical terms as English terms rather than forcing Sanskritized coinages. Translate the connective prose, not the jargon. Register: professional, clear, never bureaucratic (सरकारी हिंदी), never slangy. + +## 1. Register and address + +- Address the reader as **आप** only. Never तू/तुम. Verbs take the आप form: करें, चलाएँ, देखें, कर सकते हैं. +- Imperative style: use the **-एँ** form consistently (चलाएँ, बनाएँ, जोड़ें). Do not mix in कीजिए/कीजिये or करो within a page. +- Docs: terse, instructional, present tense. Blog: same person, slightly warmer; the project may speak as हम. +- No exclamation marks, no rhetorical questions, no emoji, no filler (आइए जानते हैं, तो चलिए शुरू करते हैं). +- EN "You can now access the dashboard." → ✗ "अब आप डैशबोर्ड को एक्सेस कर सकते हैं!" → ✓ "अब आप dashboard खोल सकते हैं।" + +## 2. How much English to keep — the central rule + +Test each term with one question: **would an Indian DevOps engineer say this word in English while speaking Hindi?** If yes, it stays an English word. Then decide the script by bucket: + +1. **Latin script, verbatim** — anything the reader must type, match, or click: product and project names (Cozystack, Kubernetes, Talos, Flux), CLI names and flags (kubectl, --namespace), API kinds and fields (Pod, Deployment, Secret, Node, ConfigMap, StorageClass, spec.replicas), file names, env vars, UI labels, acronyms (API, CNI, CSI, RBAC, TLS, YAML, GPU). +2. **Devanagari transliteration of the English word** — only for the general infra nouns the injected glossary assigns a Hindi rendering (e.g. cluster/node/storage as ordinary nouns). Use the glossary form exactly; never invent new transliterations for terms it does not list. +3. **Real Hindi** — everything else: verbs, adjectives, connectors, and ordinary nouns (उपलब्ध, कॉन्फ़िगर करें, इसके बाद, ध्यान दें, अनुमति, आवश्यकता). + +Default for a term not covered by the glossary: **keep it in Latin.** Never coin a Devanagari equivalent for infra jargon. + +- ✗ संगणक, कुंजीपटल, अभिकलन, स्मृति, संचिका, जालक्रम, कूटशब्द, आभासी संगणित्र → ✓ computer, keyboard, compute, memory, file, network, password, VM +- EN "Configure the ingress controller and the load balancer." → ✗ "प्रवेश नियंत्रक और भार संतुलक को विन्यस्त करें।" → ✓ "ingress controller और load balancer कॉन्फ़िगर करें।" +- EN "Kubernetes schedules the pod on a node." → ✗ "कुबेरनेट्स पॉड को एक नोड पर शेड्यूल करता है।" (brand transliterated) → ✓ "Kubernetes पॉड को नोड पर schedule करता है।" +- Over-translation is as bad as under-translation: EN "high availability" → ✗ "उच्च उपलब्धता" is acceptable only if the glossary says so; otherwise keep "high availability". + +## 3. Headings + +- Devanagari has no letter case — there is nothing to title-case. Do not capitalize or restyle; just write a natural phrase. +- No terminal । in headings. No trailing colon. +- English terms inside a heading keep exactly the form used in the body — same script, same capitalization. ✗ heading "क्लस्टर इंस्टॉल करना" + body "cluster" (script drift). +- Prefer verbal nouns or short noun phrases, consistently within a page: EN "Installing Cozystack" → ✓ "Cozystack इंस्टॉल करना"; EN "Prerequisites" → ✓ "आवश्यक शर्तें"; EN "Troubleshooting" → ✓ "समस्या निवारण". + +## 4. Terminology policy (terms outside the glossary) + +Decision order: glossary `do_not_translate` → verbatim Latin; glossary `preferred` → that exact rendering, every occurrence; otherwise apply §2 and default to Latin. + +- **API kinds stay Latin and singular in prose**: "Pod को delete करें", "Deployment में replicas बढ़ाएँ", "यह Secret namespace में बनता है". Never transliterate a kind (✗ डिप्लॉयमेंट ऑब्जेक्ट for `kind: Deployment`). +- Mirror the glossary's Tenant/tenant split: capitalized API kind (`Node`, `Tenant`) → Latin; the same word as an ordinary noun (a node, a tenant) → the glossary's Devanagari form. +- **First mention**: when a Devanagari-rendered term first appears on a page, gloss it once — "टेनेंट (tenant)" — then use the plain form. Do not repeat the gloss. +- One term = one rendering per page and per docs version. If you chose Latin once, it is Latin everywhere on that page. +- Never attach Devanagari inflection to a Latin word: ✗ Podों, ✗ Deploymentस, ✗ नोडnode. Express plurality with a quantifier: ✓ "कई Pod", "सभी Node", "दो PVC". + +## 5. Script and typography + +- End full sentences in prose with **पूर्ण विराम ।**, including sentences that end on a Latin word: ✓ "इसके बाद `kubectl apply` चलाएँ।" Never write ".।". Never place । inside code, headings, or list fragments that are not sentences. +- Keep the period for version numbers, decimals, abbreviations and anything inside code (v1.2.5). +- Use straight double quotes " " for quoted UI strings and values; keep the quoted English string in Latin. Avoid mixed ‘ ’/“ ” pairs. +- One space between a Devanagari run and an adjacent Latin/number run; no space before ।, comma, or a closing bracket. Postpositions after Latin words are separated by a space: "Node पर", "namespace में". +- Never mix scripts inside a single word, and never transliterate identifiers, flags, paths, or commands. + +## 6. Numbers, dates, units + +- **Western Arabic digits only** (1, 2, 3). Never Devanagari digits (१, २, ३). +- Use **international grouping** — 1,000 / 1,000,000. Do **not** use Indian grouping (✗ 10,00,000) and never convert to lakh/crore in technical content. +- Decimal point is `.` — ✓ 1.5 GB. +- Dates: "24 जुलाई 2026" in prose; ISO `2026-07-24` when the source is ISO. Never MM/DD/YYYY. +- Units keep Latin symbols with a space: 16 GB, 500 ms, 4 vCPU, 10 Gbit/s. Percent has no space: 50%. Ranges: "2–4 GB" or "2 से 4 GB". + +## 7. Grammar traps (EN → HI) + +- **SOV order.** EN "Cozystack deploys the application to the cluster." → ✗ "Cozystack डिप्लॉय करता है application को क्लस्टर में।" → ✓ "Cozystack application को क्लस्टर में deploy करता है।" The verb goes last. +- **Gender of English loanwords.** Consonant-final loans are **masculine**: क्लस्टर, नोड, पॉड, सर्वर, नेटवर्क, स्टोरेज, बैकअप, प्लेटफ़ॉर्म. Feminine: फ़ाइल, मशीन, सर्विस, इमेज, and -ी endings (मेमोरी, डायरेक्टरी, लाइब्रेरी). Keep gender consistent across the whole page: ✗ "क्लस्टर बनाई गई" → ✓ "क्लस्टर बनाया गया"; ✗ "फ़ाइल बनाया गया" → ✓ "फ़ाइल बनाई गई". +- **का/की/के agreement** follows the *possessed* noun: ✓ "क्लस्टर की स्थिति", "नोड का नाम", "Pod के logs". +- **Postpositions.** in → में, on → पर, from → से, for → के लिए. ✗ "क्लस्टर के लिए deploy करें" for "deploy to the cluster" → ✓ "क्लस्टर में deploy करें". +- **Ergative ने** only with perfective transitive verbs: ✓ "हमने क्लस्टर बनाया"; ✗ "Cozystack ने क्लस्टर बनाता है" (imperfective — no ने); ✗ ने with intransitives ("Pod ने शुरू हुआ"). +- **Compound verbs** read naturally; do not strip them: ✓ "config फ़ाइल लिख दें", "logs देख लें" where the source implies completion. Do not add them where the source is neutral. +- Honorific verb forms must stay consistent — do not switch between "करें" and "करते हैं" for the same instructional voice. + +## 8. Translationese and calque patterns to fix + +- **"आप ... कर सकते हैं" everywhere.** In step-by-step docs use the imperative. EN "You can create a tenant with kubectl." → ✗ "आप kubectl का उपयोग करके एक टेनेंट बना सकते हैं।" → ✓ "kubectl से टेनेंट बनाएँ।" +- **एक as an article.** Hindi has no indefinite article. ✗ "एक क्लस्टर बनाएँ" → ✓ "क्लस्टर बनाएँ". Keep एक only when the count matters. +- **Literal passive.** EN "The cluster is created by the operator." → ✗ "क्लस्टर ऑपरेटर के द्वारा बनाया जाता है।" → ✓ "operator क्लस्टर बनाता है।" +- **English-order relative clauses.** EN "the node that runs the workload" → ✗ "नोड जो वर्कलोड चलाता है" → ✓ "वर्कलोड चलाने वाला नोड" (or जो … वह with correct correlative). +- **"का उपयोग करके" / "के माध्यम से" stacked in every sentence** → prefer "से": ✓ "Helm से इंस्टॉल करें". +- **"यह सुनिश्चित करें कि" / "कृपया"** in every step — drop them; docs are already polite via आप. +- Word-by-word renderings of English idiom ("out of the box" → ✗ "डिब्बे से बाहर") → ✓ "बिना अतिरिक्त कॉन्फ़िगरेशन के" or keep "out of the box". + +## 9. Reviewer checklist (MT failure modes) + +1. Sanskritized coinage anywhere (संगणक, कुंजीपटल, अभिकलन, स्मृति, संचिका) → reject. +2. Brand/product transliterated (कुबेरनेट्स, कोज़ीस्टैक, कुबेक्टल) → must be Latin. +3. Same term in two scripts or two spellings on one page (cluster vs क्लस्टर, स्टोरेज vs भंडारण). +4. Gender drift on loanwords across paragraphs; का/की/के disagreement. +5. English SVO order preserved; verb not final. +6. Wrong postposition, or में/पर/के लिए swapped. +7. ने inserted with imperfective or intransitive verbs. +8. Passive constructions copied from English. +9. "आप … कर सकते हैं" or "एक" repeated in nearly every sentence. +10. Devanagari digits, Indian digit grouping, lakh/crore, MM/DD dates. +11. Missing or doubled ।; । after a heading; । inside code. +12. Devanagari suffixes glued to Latin words (Podों), or a translated flag/identifier (`--नाम`). +13. Mixed imperative registers (चलाएँ vs चलाइए vs चलाओ) in one page. +14. Terminology diverging from the injected glossary. + +## 10. Pipeline note + +Code blocks, inline code, Hugo shortcodes, URLs, HTML attributes and front-matter keys are masked structurally by the pipeline. Never translate, reorder, transliterate or re-space anything inside a mask; front-matter `title` and `description` values are translated, their keys are not. diff --git a/hack/i18n/style-guides/pt-br.md b/hack/i18n/style-guides/pt-br.md index 1cff6cc6..d988e996 100644 --- a/hack/i18n/style-guides/pt-br.md +++ b/hack/i18n/style-guides/pt-br.md @@ -1,5 +1,80 @@ -- 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. +Brazilian Portuguese (pt-BR), never European Portuguese. Follow kubernetes.io pt-BR localization conventions and the CNCF Cloud Native Glossary. Natural BR phrasing beats literal calques: if a sentence can only be parsed by reconstructing the English, rewrite it. Code blocks, inline `code`, Hugo shortcodes, URLs, front-matter keys and §§PLACEHOLDERS§§ are masked by the pipeline — never translate or reorder them. + +## 1. Register and address + +- Always **você** (explicit or implicit), consistently within a page. Never *tu*, *vós*, *o senhor*, and never mix conjugations: ✗ "se tu quiseres", ✗ "podes ver" → ✓ "se você quiser", "você pode ver". +- Friendly-professional. Docs: imperative, short sentences, one instruction per sentence ("Execute o comando", "Verifique o status"). Drop English politeness: "Please run" → ✗ "Por favor, execute" → ✓ "Execute". +- Blog/landing: transcreate. First person plural is fine ("lançamos", "nossa equipe"); avoid hype adjectives — *powerful* → ✗ "poderoso" → ✓ "robusto", "eficiente", or drop. +- The product takes an article in running prose: ✓ "o Cozystack instala…", "no Kubernetes"; omit it in labels and titles. + +## 2. Headings and capitalization (critical) + +- Portuguese uses **sentence case**, never English title case: capitalize only the first word and proper nouns. "Installing the Storage Backend" → ✗ "Instalando o Backend de Armazenamento" → ✓ "Instalação do backend de armazenamento". +- Prefer a noun phrase (or infinitive) over the English gerund heading, consistently within a page: "Configuring Networking" → ✗ "Configurando a Rede" → ✓ "Configuração de rede" / "Configurar a rede". +- Do not capitalize common nouns mid-sentence because English did: "the Cluster and the Node" → ✓ "o cluster e o nó". +- Same rule for `title`/`description` front matter, table headers, alt text, button and menu labels. + +## 3. Terminology policy (a glossary is injected separately — do not restate it) + +- Decision rule for terms **not** in the glossary, in order: (1) kubernetes.io pt-BR translates it → use that; (2) the CNCF Glossary pt-BR has it → use that; (3) a BR platform engineer would say the English word out loud in a standup → keep English; (4) otherwise translate, with the English in parentheses on first use only. +- Kept in English (real BR usage): pod, cluster, deploy, container, namespace, commit, pull request, backup, snapshot, endpoint, cache, log, bucket, overhead, hypervisor, bare metal, load balancer. +- Translated: workload → carga de trabalho, node → nó, storage → armazenamento, network → rede, image → imagem, file → arquivo, feature → recurso, wizard → assistente, release → versão/lançamento. +- **API objects**: when the word names a Kubernetes/Cozystack API kind, keep it capitalized and in English — "o Pod", "um Deployment", "o Secret", "o objeto Node", "o Tenant" (must match `kind:` in the manifest). When it is the everyday concept, translate and lowercase: "os pods do cluster", "os nós do cluster", "os segredos da aplicação". +- **Gender**: masculine by default for English loanwords (o pod, o cluster, o deploy, o container, o backup, o node, o namespace, o endpoint); feminine when the underlying PT head noun is feminine (a imagem, a API, a VM, a URL, a tag, a role). ✗ "a cluster", ✗ "o VM", ✗ "a pod". +- **Plural**: inflect with -s, no apostrophe — os pods, os clusters, os deployments, os namespaces, as VMs, as APIs, os PVCs. ✗ "os pod", ✗ "as VM's". Adjectives agree: "pods prontos", "cargas de trabalho críticas". + +## 4. Typography and punctuation + +- Quotation marks: **straight double quotes** `"…"`, nested `'…'`. Do not use «guillemets» (pt-PT/literary) or curly quotes — the docs are code-adjacent and quoted strings get copied into shells. +- **Travessão (—)** with spaces for parenthetical asides; keep the source's em dashes, never downgrade them to a hyphen: ✓ "O tenant — a unidade de isolamento — recebe seu próprio namespace." +- **No Oxford comma.** Portuguese forbids a comma before *e*/*ou* closing an enumeration: "storage, networking, and compute" → ✗ "armazenamento, rede, e computação" → ✓ "armazenamento, rede e computação". +- Comma after a fronted adverbial or subordinate clause: ✓ "Depois de instalar o Cozystack, aplique o manifesto." Never a comma between subject and verb, however long the subject: ✗ "O cluster com três nós de controle, requer…". +- **Crase (à)** — verify every occurrence. Use it when preposition *a* + feminine article *a* merge: ✓ "acesso à API", "conecte-se à rede", "à medida que", "devido à latência", "graças à replicação". No crase before masculine nouns, verbs or article-less plurals: ✗ "à partir de" → ✓ "a partir de"; ✗ "à fim de" → ✓ "a fim de"; ✗ "acesso à documentos" → ✓ "acesso a documentos"; ✓ "referente ao cluster". +- Use "e", never "&". Keep `%`, `/` and CLI punctuation exactly as in the source. + +## 5. Numbers, dates, units + +- **Decimal comma in prose**: "3.14" → ✓ "3,14"; "0.5 vCPU of overhead" → ✓ "0,5 vCPU de overhead". Thousands separator is a period: "1,000 nodes" → ✓ "1.000 nós"; "12,500" → ✓ "12.500". +- **Never** convert inside code, CLI output, YAML values, resource quantities, CIDRs or version numbers: `v1.2.3`, `0.5`, `500m`, `10.0.0.0/8` stay exactly as written — including when quoted in prose. +- Dates: "July 24, 2026" → ✓ "24 de julho de 2026" (month lowercase); numeric 24/07/2026; ranges "de 2024 a 2026". 24-hour clock: "9 AM UTC" → ✓ "9h UTC" / "09:00 UTC". +- Units: space between number and unit (10 GB, 500 ms, 2 vCPU, 4 GiB); no space before `%` (50%); keep byte/rate units in standard casing (GiB, TiB, Mbps). + +## 6. Grammar traps translating from English + +- **Gerundismo / -ing calque** — the notorious BR error. English -ing → infinitive or noun; never "estar + gerúndio" for a future or habitual action: ✗ "vamos estar enviando o manifesto" → ✓ "vamos enviar o manifesto"; "Before installing, check the requirements" → ✗ "Antes de instalando" → ✓ "Antes de instalar, verifique os requisitos". Genuine progressives are fine: ✓ "o pod está iniciando". +- **Passive**: agentless English passive → active imperative or synthetic passive with *se*: "The manifest is applied automatically" → ✓ "Aplica-se o manifesto automaticamente" / ✓ "O Cozystack aplica o manifesto automaticamente". In procedures, prefer the imperative. +- "you can" → alternate "você pode" with impersonal "é possível" so a page does not repeat "você pode" ten times. +- **Possessives → definite article**: "Open your terminal and check your cluster" → ✗ "Abra o seu terminal e verifique o seu cluster" → ✓ "Abra o terminal e verifique o cluster". +- **Noun stacking → de-chains, max two links**: "Kubernetes cluster node pool autoscaling configuration" → ✗ "configuração do autoscaling do pool de nós do cluster Kubernetes" → ✓ "configuração do autoscaling para pools de nós (cluster Kubernetes)". +- **ser vs estar**: definitional → *ser* ("O Cozystack é uma plataforma"); state/condition → *estar* ("O cluster está pronto", "o pod está em execução"). ✗ "O cluster é pronto". +- **Future subjunctive after quando/se/assim que**: "When you create the tenant" → ✗ "Quando você cria o tenant" → ✓ "Quando você criar o tenant"; "If the pod fails" → ✓ "Se o pod falhar"; "as soon as it is ready" → ✓ "assim que estiver pronto". +- **Colocação pronominal (BR = próclise)**: ✗ "Não aplica-se a clusters legados" → ✓ "Não se aplica a clusters legados". Never open a sentence with an unstressed pronoun; avoid pt-PT mesoclisis ✗ "permitir-lhe-á" → ✓ "vai permitir que você". +- **Future tense**: BR prefers "vai + infinitivo" or the simple present: "This will create a namespace" → ✗ "Isto irá criar um namespace" → ✓ "Isso cria um namespace". + +## 7. Translationese and calques to eliminate + +- "In this article, we will…" → ✗ "Neste artigo, nós vamos…" → ✓ "Veja como…" / "Este guia mostra…". +- Delete filler — *simply*, *just*, *easily*: ✗ "simplesmente execute" → ✓ "execute". +- support → ✗ "suportar" (= to endure) → ✓ "oferecer suporte a", "ser compatível com", "aceitar": "Cozystack supports GPU passthrough" → ✓ "O Cozystack oferece suporte a GPU passthrough". +- application → ✓ "aplicação" (server-side workloads); "aplicativo" only for mobile/desktop apps. +- encrypt → ✗ "encriptar" → ✓ "criptografar" / "criptografia". library → ✗ "livraria" → ✓ "biblioteca". customize → ✗ "customizar" → ✓ "personalizar". delete → ✗ "deletar" → ✓ "excluir"/"remover". set → ✗ "setar" → ✓ "definir"/"configurar". address (an issue) → ✗ "endereçar" → ✓ "tratar"/"resolver"/"corrigir". +- requirement → ✗ "requerimento" (= a petition) → ✓ "requisito". performance → ✓ "desempenho". check → ✓ "verificar" (not "checar"). issue → ✓ "problema" (keep "issue" only for a GitHub issue). +- "Once you have installed…" → ✗ "Uma vez que você instalou" (= *because*) → ✓ "Depois de instalar" / "Assim que instalar". "Assuming that…" → ✓ "Supondo que". "in order to" → ✓ "para" (not "de forma a"). "make sure" → ✓ "certifique-se de que"; "note that" → ✓ "observe que"; "keep in mind" → ✓ "lembre-se de que". + +## 8. False friends (EN → PT) + +actually → ✓ "na verdade" (✗ atualmente = *currently*) · eventually → ✓ "por fim", "com o tempo" (✗ eventualmente = *occasionally*) · library → ✓ biblioteca · support → ✓ oferecer suporte a · realize → ✓ "perceber", "notar" (✗ realizar = *to carry out*) · pretend → ✓ "fingir" (✗ pretender = *to intend*) · push (git) → keep "push" or ✓ "enviar" (✗ "empurrar") · assume → ✓ "supor", "presumir" (✗ assumir = *to take on*) · resume → ✓ "retomar" (✗ resumir = *to summarize*) · comprehensive → ✓ "abrangente" (✗ compreensivo = *understanding*) · sensible → ✓ "sensato" (✗ sensível = *sensitive*) · attend → ✓ "participar de" (✗ atender = *to serve*) · exit → ✓ "sair" (✗ êxito = *success*) · policy → ✓ "política" (never "polícia") · parent (resource) → ✓ "pai"/"superior". + +## 9. Reviewer checklist — pt-BR machine-translation failure modes + +1. **European Portuguese leaking in** — reject on sight: ficheiro→arquivo, utilizador→usuário, ecrã→tela, rato→mouse, aceder a→acessar, guardar→salvar, apagar→excluir, equipa→equipe, registo→registro, facto→fato, contacto→contato, "está a executar"→"está executando", connosco→conosco, gestor→gerenciador, telemóvel→celular. +2. English **title case** surviving in headings, `title`/`description`, table headers or link text. +3. Decimal points left as "3.14"/"1,000" in prose — or commas wrongly injected into code, versions, CIDRs. +4. Missing or hypercorrect **crase** ("à partir de", "acesso a API"). +5. Comma before *e*/*ou*, or a comma splitting subject from verb. +6. Gender/number disagreement on loanwords ("a cluster", "os pod", "VM's"). +7. Gerundismo, "irá + infinitivo" future, enclisis after negation ("não aplica-se"). +8. Literal "suportar", "customizar", "deletar", "encriptar", "endereçar", "livraria", "uma vez que", "simplesmente". +9. Register drift: *tu*/*você* mixing, "por favor" in imperatives, marketing hype in docs, stiff formality in blog. +10. Untranslated or dropped sentences; translated API kinds, CLI flags, YAML keys or URLs — all hard failures. +11. Glossary terms rendered inconsistently across a page (e.g. "nó" in one paragraph, "node" in the next). diff --git a/hack/i18n/style-guides/ru.md b/hack/i18n/style-guides/ru.md index 237450d1..df4fd6c3 100644 --- a/hack/i18n/style-guides/ru.md +++ b/hack/i18n/style-guides/ru.md @@ -1,5 +1,96 @@ -- Обращение к читателю — на «вы» со строчной буквы (нейтрально-профессионально). -- Инфинитивные заголовки («Установить кластер»), как в kubernetes.io/ru. -- Англицизмы, укоренившиеся в инфре, не переводить силой: «под» (pod — можно оставлять pod в коде), «нода»/«узел» — «узел» в прозе, «деплой»/«развёртывание» — предпочитать «развёртывание». -- Кавычки — «ёлочки». Тире — длинное (—). -- Не переводить: kubectl, namespace (в командах), CLI-флаги. +Этот файл — одновременно инструкция переводчику и рубрика для ревью. Каждый пункт проверяем. +Код-блоки, инлайн-код, шорткоды Hugo, URL и ключи front matter замаскированы пайплайном — их не переводят и не трогают ни при каких условиях; правила ниже касаются только прозы. + +## 1. Регистр и обращение + +- Обращение к читателю — на «вы» со строчной буквы (нейтрально-профессионально). «Вы» с прописной — ошибка. «Ты» — ошибка. +- Документация: безличный/императивный тон, без «мы», без эмоций. EN «We recommend using X» → ✗ «Мы рекомендуем использовать X» → ✓ «Рекомендуется использовать X». +- Блог и лендинг: «мы» допустимо, если это команда проекта; можно транскреация, но без восклицательных знаков, превосходных степеней и рекламных клише («революционный», «мощнейший»). +- EN «Please note that…» → ✓ «Обратите внимание:…» (никогда «пожалуйста»). EN «Let's create…» → ✓ «Создайте…». + +## 2. Заголовки + +- Инфинитивные заголовки, как в kubernetes.io/ru: EN «Installing a cluster» / «Install a cluster» → ✗ «Установка кластера» → ✓ «Установить кластер». +- Заголовки-существительные оставляем существительными: «Architecture» → «Архитектура», «Requirements» → «Требования». +- Заголовки-вопросы сохраняем: «Why Cozystack?» → «Почему Cozystack?». +- Английский Title Case → русская прописная только в первом слове и в именах собственных: «Configure Storage Backend» → ✗ «Настроить Хранилище Данных» → ✓ «Настроить бэкенд хранилища». +- Точка в конце заголовка не ставится. То же правило применяется к `title` и `description` во front matter. + +## 3. Терминология: правило решения (для терминов вне глоссария) + +Глоссарий (do_not_translate + preferred) инжектится отдельно и имеет приоритет. Для всего остального — по порядку: +1. Есть устоявшийся перевод в kubernetes.io/ru или CNCF Cloud Native Glossary → берём его. +2. Нет перевода, но термин читатель видит в CLI, UI, логах или манифестах → оставить латиницей, не склонять, добавить родовое слово: «в объекте PersistentVolumeClaim», «через контроллер Flux». +3. Есть точный русский аналог, не мешающий поиску → перевести, при первом упоминании на странице дать оригинал в скобках: «отказоустойчивость (fault tolerance)». Дальше — только русский вариант. +4. Транслитерация допустима только для уже укоренившихся форм: под, тенант, кластер, бэкенд, деплой. Не изобретать новых («кубелет», «инстанс» в доке — ✗, «экземпляр» — ✓). +5. Один термин — один перевод на всей странице и на всём сайте. Синонимическое разнообразие здесь — дефект, а не стиль. + +Объекты API Kubernetes: +- Как kind в манифесте — латиницей, с прописной, без склонения, с родовым словом: «создайте ресурс Deployment», «в поле spec объекта Secret». +- В обычной прозе, как нарицательное — по-русски и склоняется: pods → «поды», nodes → «узлы» (не «ноды»), secrets → «секреты», namespaces → «пространства имён» (но в командах и в описании флагов — namespace как есть). +- Устоявшиеся англицизмы инфраструктуры не переводить силой: «под» (в коде — pod), «узел» вместо «нода» в прозе, «развёртывание» предпочтительнее «деплоя». +- Не переводить и не склонять: kubectl, namespace (в командах), CLI-флаги, имена полей YAML, названия продуктов. + +## 4. Типографика и пунктуация + +- Кавычки — «ёлочки»; вложенные — „лапки“: «параметр „replicas“ в манифесте». ASCII-кавычки (" ') и “…” в прозе запрещены; внутри инлайн-кода остаются как есть. +- Тире — длинное (—), отбивается пробелами, слева — неразрывным. Дефис (-) — только внутри слов: «бэкап-политика», «SLA-требования». Диапазоны — короткое тире без пробелов: «3–5 узлов», «2024–2026». +- Пропуск глагола-связки оформляется тире: «Cozystack — платформа на базе Kubernetes». +- Многоточие — один символ «…», не «...». +- Буква ё обязательна: «развёртывание», «учётные данные», «объём», «затем». +- Неразрывный пробел: между числом и единицей («16 ГиБ», «3 узла»), внутри сокращений («и т. д.», «т. е.»), после коротких предлогов и союзов в начале синтагмы («в кластере», «с помощью»), перед длинным тире. +- Знаки препинания не отбиваются пробелом слева; двойных пробелов не бывает. +- Списки: если элементы продолжают вводную фразу — со строчной буквы и «;» на конце, последний — «.». Если элементы самостоятельные предложения — с прописной и точкой. Английский стиль (Title Case в пунктах, точки через один) не переносить. + +## 5. Числа, даты, единицы + +- Десятичный разделитель в прозе — запятая: «3.14» → «3,14»; «0.5 CPU» → «0,5 CPU». КРИТИЧНО: внутри кода, версий, тегов, значений YAML, размеров в командах разделитель НЕ меняется никогда: `v1.2.5`, `cpu: 0.5`, «версия 1.2.5» остаются с точкой. +- Разряды тысяч — неразрывный пробел, не запятая: «10,000 pods» → «10 000 подов». +- Проценты — слитно с числом: «50%». Диапазон — «10–15%». +- Единицы измерения в техническом контексте — латиницей и в оригинальной форме (GiB, Mi, vCPU, IOPS, ms), так как совпадают с тем, что читатель видит в манифестах. В блоге и маркетинге допустима кириллица (ГБ, ТБ) — но единообразно в пределах страницы. +- Даты в прозе — по-русски: «July 24, 2026» → «24 июля 2026 года». В командах, тегах, front matter и changelog формат ISO не трогать. +- Числа и существительные согласуются: «1 узел», «2 узла», «5 узлов». MT часто оставляет «5 узла» — проверять. + +## 6. Грамматические ловушки при переводе с английского + +- Артикли исчезают: «a cluster», «the node» → «кластер», «узел». Не превращать «a» в «один», «the» в «данный». +- Английский пассив → русский актив или безличная конструкция: «The volume is mounted by kubelet» → ✗ «Том монтируется kubelet-ом» → ✓ «Том монтирует kubelet» / ✓ «Том монтируется автоматически». +- Герундий в начале фразы → предлог или деепричастие, но не цепочка «используя»: «Using Flux, you can…» → ✗ «Используя Flux, вы можете…» → ✓ «С помощью Flux можно…». +- «You can / you need to» в документации → безлично: «You can override defaults» → ✓ «Значения по умолчанию можно переопределить». «Вы можете» оставляем только там, где важно противопоставление ролей (что может пользователь, а что — администратор). +- Цепочки существительных разворачиваются в родительный падеж, но не длиннее двух звеньев подряд: «Kubernetes cluster node pool configuration» → ✗ «конфигурация пула узлов кластера Kubernetes» → ✓ «настройка пулов узлов в кластере Kubernetes». Третий родительный подряд — разбить предлогом или прилагательным. +- Порядок слов: известное — в начало, новое — в конец. «A new tenant is created by the administrator» → ✓ «Новый тенант создаёт администратор» (если рема — кто) / ✓ «Администратор создаёт новый тенант» (если рема — что). +- Вид глагола: инструкция — совершенный вид («Создайте секрет», «Примените манифест»); описание повторяющегося поведения — несовершенный («Контроллер проверяет состояние каждые 30 секунд»). MT часто ставит несовершенный в шагах — ошибка. +- Согласование с аббревиатурами — по роду опорного слова: «API возвращает» (интерфейс), «ОС поддерживает» (система), «СУБД настроена» (система). Латинские аббревиатуры не склоняем: «через API», не «через APIшку». +- Английские имена собственные из списка do_not_translate не склоняются; если фраза становится неуклюжей — добавить родовое слово: ✗ «в Талосе» → ✓ «в Talos Linux», ✗ «Cozystack'ом» → ✓ «средствами Cozystack». +- Отрицание не терять: «must not be empty» → «не должно быть пустым», а не «должно быть пустым». + +## 7. Переводческие штампы и кальки (вычищать) + +- «В данной статье мы рассмотрим…» → «Здесь описано…» / «В статье описано…». «Данный» → «этот». +- «является» вместо тире: «Cozystack является платформой» → ✓ «Cozystack — платформа». +- «позволяет вам», «предоставляет возможность», «дает возможность» → «позволяет», «даёт», а лучше прямое действие: «Flux allows you to sync manifests» → ✓ «Flux синхронизирует манифесты». +- «просто», «легко», «всего лишь» как калька от «simply/easily» — удалять: «Simply run the command» → ✓ «Выполните команду». +- «поддерживается» в каждом абзаце (support): «X supports Y» → «X работает с Y» / «в X есть Y» / «X совместим с Y» — варьировать по смыслу. +- «для того чтобы» (от «in order to») → «чтобы»; «в случае, если» → «если»; «в настоящее время» → «сейчас»; «осуществлять настройку» → «настраивать». +- «Обратите внимание, что» — только там, где в оригинале Note/Warning. Цепочки «который… который…» — разбивать на предложения. +- Буквальные «пожалуйста», «наслаждайтесь», «мы рады сообщить» — не переносить. + +## 8. Ложные друзья + +actual → «фактический», не «актуальный» · consistent → «согласованный», «единообразный», не «консистентный» · control (control plane, access control) → «управление», не «контроль» · validate → «проверить», не «валидировать» (кроме валидации схемы API) · accurate → «точный» · dramatic(ally) → «резко», «значительно», не «драматически» · eventually → «в итоге», «в конечном счёте» · transparent → «незаметный для пользователя», редко «прозрачный» · native → «встроенный», «собственный» (но cloud native — не переводим) · performance → «производительность», не «перформанс» · capacity → «ёмкость», «мощность» · executive → «руководитель» · application → «приложение», но в бизнес-контексте может быть «заявка» · commit (git) → не переводим; commitment → «обязательство» · legacy → «унаследованный», «устаревший» · instance → «экземпляр» · provision → «выделить», «подготовить» · resolution → «разрешение» (DNS) или «решение проблемы» — различать · scale → «масштабировать», не «шкалировать» · security → «безопасность», не «секьюрити». + +## 9. Чеклист ревьюера: типовые сбои машинного перевода на русский + +- Заголовок не в инфинитиве или скопирован Title Case. +- «Вы» с прописной; смена регистра обращения внутри страницы. +- ASCII-кавычки или английские “…” в прозе; дефис вместо тире; «...» вместо «…»; пропущенная ё; двойные пробелы; отсутствие неразрывных пробелов в «16 ГиБ», «и т. д.». +- Десятичная точка в прозе — или, наоборот, запятая, вкравшаяся в версию/код/значение YAML; английский разделитель тысяч (10,000). +- Один и тот же термин переведён по-разному в разных абзацах; термин из глоссария проигнорирован. +- Переведено то, что переводить нельзя: имя kind, флаг, поле YAML, часть URL, текст внутри плейсхолдера §§…§§. +- Склонение или транслитерация латинских имён собственных («Кубернетесе», «Козистэк»). +- Рассогласование числительного и существительного, рода при аббревиатуре. +- Английская синтаксическая рамка: пассив, «вы можете», «используя», цепочки из трёх и более родительных падежей. +- Штампы из раздела 7 («является», «в данной статье», «позволяет вам», «просто»). +- Потерянное отрицание, потерянные условия («only if», «unless»), перепутанные «may/must/should». +- Незакрытая логика списка: пункты с разной пунктуацией и разным регистром первой буквы. +- Нарушена структура Markdown: съеден уровень заголовка, потеряна колонка таблицы, переведён URL внутри ссылки. diff --git a/hack/i18n/style-guides/zh-cn.md b/hack/i18n/style-guides/zh-cn.md index 78654b3a..fd7d71e1 100644 --- a/hack/i18n/style-guides/zh-cn.md +++ b/hack/i18n/style-guides/zh-cn.md @@ -2,3 +2,94 @@ - 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). + +### 1. Register and address + +- Docs: 书面语 — precise, impersonal, imperative for steps. Blog/landing: livelier, shorter sentences, rhetorical questions allowed; never 网络流行语 or exclamation spam. +- Address the reader as **你**, never 您. This is the kubernetes.io/zh-cn convention: 您 reads deferential/sales-y and is impossible to keep consistent across a large doc set. Better still, drop the pronoun. EN "You can install it with Helm" → ✗ 您可以使用 Helm 来安装它 → ✓ 使用 Helm 安装即可。 +- Mixing 你 and 您 on one page is a defect even if each sentence reads fine. +- First person plural only in signed blog posts. Docs: ✗ 我们建议启用备份 → ✓ 建议启用备份。 + +### 2. Headings + +- No trailing 。 in any heading. ?and !are allowed when the source is a question/exclamation: "What is Cozystack?" → ✓ 什么是 Cozystack? +- Concept/reference headings → noun phrase: "Storage Architecture" → ✓ 存储架构(✗ 存储是如何架构的)。 +- Task headings → 动宾 verb phrase, no 正在 / 如何进行 padding: "Installing Cozystack" → ✗ 正在安装 Cozystack、✗ 如何进行 Cozystack 的安装 → ✓ 安装 Cozystack。 +- Conventional renderings: "Getting Started" → 快速开始;"Prerequisites" → 前置条件;"Troubleshooting" → 故障排查;"See also" → 参见;"Next steps" → 后续步骤。 +- Do not add numbering the source lacks; keep heading levels unchanged. + +### 3. Terminology policy (terms NOT in the injected lists) + +Decision order: (1) kubernetes.io/zh-cn glossary → (2) CNCF Cloud Native Glossary 中文版 → (3) rules below. Never coin a new Chinese term for a concept the Chinese k8s community discusses in English. +- **API kinds, CRD kinds, field names, flags, CLIs stay Latin and uninflected**: Pod、Deployment、StatefulSet、DaemonSet、Secret、ConfigMap、Ingress、kubelet、kubectl。✗ 豆荚 / 入口 / 秘密 / 配置映射。 +- **The same word as an ordinary noun is translated** per k8s zh: node→节点、namespace→命名空间、volume→卷、label→标签、annotation→注解、container→容器、image→镜像、replica→副本、control plane→控制平面、service→服务。If the sentence means `kind: Node` / `kind: Service`, keep Node / Service in Latin. +- Generic concepts with stable renderings are translated: 高可用、可观测性、编排、调度、快照、备份、故障转移、隔离、租户。 +- Unknown/new term with no stable rendering: keep Latin, or use 中文(English)on **first mention per page**, Chinese only afterwards — ✓ 单节点控制平面(single-node control plane)。Never repeat the bracketed English on every occurrence. +- One page = one rendering per term. ✗ 租户 … 承租方 … 租客 in the same file. + +### 4. Punctuation + +- Full-width only in Chinese prose: ,。:;!?()【】、 — ASCII `,` `.` `;` `:` in Chinese prose is always a defect. +- **顿号 、 separates coordinate items inside a sentence** (English has no equivalent — do not copy the source commas): "CPU, memory, and storage" → ✗ CPU,内存,和存储 → ✓ CPU、内存和存储。 +- Quotes: “ ” (inner ‘ ’) for quoted prose and UI strings; 《》 for titles of docs/books/specs: ✓ 详见《Cozystack 安装指南》。✗ "Cozystack 安装指南"。 +- Ellipsis is ……(two full-width chars), dash is ——, never `...` or ` - `. Ranges: ✓ 3~5 个节点 / 3 到 5 个节点。 +- Half-width is correct and required inside code, inline code, versions (v1.5.0), paths, URLs, decimals (99.9), flags (`--dry-run`), and inside a fully English quoted string. +- No doubled punctuation: ✗ 部署完成。)→ ✓ 部署完成)。 Bullets and table cells: no 。 on fragments; 。 on full sentences, consistently within one list. +- Parentheses inside a Chinese sentence are full-width () even when the content is Latin: ✓ 控制平面(control plane)。 + +### 5. Spacing (pangu) + +- One space between a Chinese run and an adjacent Latin/digit run: ✓ 使用 Helm 部署 3 个副本。 ✗ 使用Helm部署3个副本。 +- **No** space between Chinese/Latin and full-width punctuation: ✗ 安装 Cozystack ,然后… → ✓ 安装 Cozystack,然后…;✗ 存储 、 网络 → ✓ 存储、网络。 +- Keep the space around masked inline-code spans too: ✓ 运行 `kubectl get pods` 查看状态。 +- Never insert pangu spaces inside code, paths, URLs, or masked placeholders. +- Never use full-width Latin letters or digits (✗ Kubernetes、✗ 10); no double spaces; no space before a line-final 。 + +### 6. Numbers, dates, units + +- Arabic numerals for all quantities, versions, dates, ports, sizes. Chinese numerals only in fixed expressions (一致、一次性、第三方). Procedures: ✓ 第 1 步 / 步骤 1。 +- 万/亿 are fine in blog prose for round numbers(✓ 10 万个 Pod); in docs keep the source's exact figures and separators (1,000 QPS stays 1,000 QPS). Never rescale magnitudes silently. +- English "billion" = 10 亿, "trillion" = 万亿 — a classic MT slip; verify every large number against the source. +- Dates: ✓ 2026年7月24日(no spaces around 年月日); ✗ 7月24日,2026。Times: ✓ 14:30。 +- Units: space between number and Latin unit — ✓ 16 GiB、100 ms、10 Gbit/s;no space for % or Chinese measure words — ✓ 99.9%、3 个节点。 + +### 7. Grammar traps translating from English + +- **量词 are mandatory and must be right**: "three nodes" → ✗ 3 节点 → ✓ 3 个节点。一台虚拟机、一块 GPU、一份清单、一条规则、一组副本、一次请求、一个 Pod、一套集群。 +- **Attributive chains before 的**: at most one 的 per noun phrase; move the rest into a following clause. "a highly available, self-healing Kubernetes control plane deployed on bare-metal nodes" → ✗ 一个部署在裸金属节点上的高可用的能够自我修复的 Kubernetes 控制平面 → ✓ 一个高可用的 Kubernetes 控制平面,部署在裸金属节点上,并可自我修复。 +- **Articles and plurals go to zero**: ✗ Pod们被调度 → ✓ Pod 会被调度;✗ 这些的集群 → ✓ 这些集群。 +- **被 is overused by MT.** Named agent → 由: ✗ 卷被 kubelet 挂载 → ✓ 卷由 kubelet 挂载。No agent → topic-comment: ✗ 备份被存储在 S3 中 → ✓ 备份存储在 S3 中。Reserve 被 for adverse/unexpected events. +- **Split long sentences**: one English sentence with 2+ subordinate clauses → 2–3 Chinese clauses ended by 。, ideally ≤ 40 汉字 each. Do not chain them with ;. +- **Topic first (主题-述题)**: "You can configure the number of replicas in values.yaml." → ✓ 副本数量在 `values.yaml` 中配置。 +- **Condition before result**: "Restart the Pod if the probe fails." → ✓ 如果探针失败,则重启 Pod。 +- 的/地/得: 快速的部署(attributive)、快速地部署(adverbial)、部署得很快(complement)— MT confuses these constantly. + +### 8. 翻译腔 patterns to eliminate + +- 进行/做出/实现 + nominalization: ✗ 对集群进行备份操作 → ✓ 备份集群;✗ 做出配置更改 → ✓ 修改配置。 +- 一个 for every English "a": ✗ 这是一个常见的模式 → ✓ 这是常见模式。 +- 们 on generic/inanimate nouns: ✗ 用户们、开发者们、节点们 → ✓ 用户、开发者、节点。 +- 关于/对于 padding: ✗ 关于更多关于 X 的信息,请参见 Y → ✓ 更多信息参见 Y;✗ 对 X 的支持 → ✓ 支持 X。 +- "simply / just / easily": ✗ 简单地运行以下命令 → ✓ 运行以下命令(或 只需运行以下命令)。 +- "you can" on every sentence: ✗ 你可以使用 Helm 来安装它 → ✓ 使用 Helm 安装。 +- 来 as filler infinitive: ✗ 使用 kubectl 来查看日志 → ✓ 使用 kubectl 查看日志。 +- "allows/enables you to": ✗ 允许你创建租户 → ✓ 支持创建租户。 +- "It is worth noting that…": ✗ 值得注意的是,… → ✓ 注意:… +- 请 only where the source is a genuine pointer/request(✓ 请参见), not on every imperative. + +### 9. Reviewer checklist — Chinese-specific MT failure modes + +1. 繁体字或台港用词泄漏: ✗ 記憶體/軟體/網路/程式/預設/伺服器/映像檔/叢集 → ✓ 内存/软件/网络/程序/默认/服务器/镜像/集群;✗ 结点 → ✓ 节点。 +2. ASCII punctuation in Chinese prose; missing 顿号; `...` instead of ……; half-width () wrapping Chinese. +3. Missing or spurious pangu spaces; full-width Latin/digits; space before 。,、. +4. Over-translated identifiers (Pod/Ingress/Secret/kubectl rendered in Chinese) or under-translated prose (cluster/node left in English). +5. Term drift within a page; first-mention 中文(English)repeated on every occurrence. +6. 被-heavy passives; 的-chains with more than one 的; missing 量词; 的/地/得 confusion. +7. English word order in Chinese characters — read the sentence aloud; if it needs a second pass to parse, split it. +8. 你/您 mixing; register drift (marketing tone inside reference docs, or stiff 书面语 inside a blog post). +9. Number/magnitude errors, altered versions, flags, resource names, or YAML keys. +10. Untranslated leftovers, duplicated clauses, and **dropped negation**(未/不/无 lost reverses the meaning); lost admonition labels — note→说明、warning→警告、caution→注意。 + +### 10. Structural (pipeline-enforced) + +Code blocks, inline code, Hugo shortcodes(`{{< >}}` / `{{% %}}`), URLs, file paths, HTML comments and front-matter **keys** are masked as `§§NAME_N§§` placeholders: copy them through verbatim, never translate, reorder, or space-fix their contents. Translate link **text**, never the URL; keep heading levels, list markers, and table column counts identical to the source. From fe57bb38494b2fba653a0182f14d54871e3c8fa0 Mon Sep 17 00:00:00 2001 From: tym83 <6355522@gmail.com> Date: Fri, 24 Jul 2026 10:31:51 +0500 Subject: [PATCH 03/14] fix(i18n): resolve the number-formatting contradiction in the translate prompt Hard rule 5 said to keep numbers unchanged, while several style guides require a decimal comma, a different thousands separator or a different date order in prose. The model was reading two incompatible instructions. The rule now separates the two ideas it was conflating: a number's VALUE and any version or identifier are literal and must be reproduced exactly, while formatting in ordinary prose follows the language's style guide. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com> --- hack/i18n/prompts/translate.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/hack/i18n/prompts/translate.md b/hack/i18n/prompts/translate.md index 7fffc9ea..d7e80a0f 100644 --- a/hack/i18n/prompts/translate.md +++ b/hack/i18n/prompts/translate.md @@ -24,7 +24,15 @@ reads well in {{LANGUAGE}}; when it is documentation, stay precise and faithful. 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. +5. Never change the VALUE of a number, version, or measurement — a figure in the + translation must mean exactly what it meant in English. Version strings, + numbers inside code, YAML values, CIDRs, resource quantities (`512Mi`, + `0.5` CPU), ports, and tags are literal identifiers: reproduce them + character-for-character, never localizing their separators. + FORMATTING of numbers, dates and units in ordinary PROSE follows the style + guide below — several languages require a decimal comma, a different + thousands separator, or a different date order. Reformatting prose is + expected; changing a value, or touching a version/identifier, is not. ## Preferred terminology (use consistently) From 2c4cdc90533874b2cc9d048c2bed6e5b28a1d35e Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 16:00:59 +0300 Subject: [PATCH 04/14] fix(i18n): stop the deterministic checks contradicting the style guides The version-integrity check counted every bare decimal in prose and demanded byte-for-byte survival, while the style guides mandate the decimal comma there ("0.5 vCPU" -> "0,5 vCPU") and the translate prompt explicitly permits prose reformatting. Deterministic findings refire identically every revise round and the gate requires an empty findings list, so a page with any prose decimal could never pass: it burned all revise rounds on every source change and shipped -with-findings forever. The German thousands rule (10,000 -> 10.000) additionally tripped the invented-token check. Enforce only unambiguous version shapes (v-prefixed or three components); bare two-part decimals are the reviewers' job. Count do-not-translate terms on word boundaries so a term is not demanded back for occurrences inside larger words. Anchor the Spanish inverted punctuation rules to sentence start: matching at any capitalized word flagged every correctly opened question containing a brand name. Align the translate prompt and the pt-BR guide on the same rule: quantities in code stay literal, bare decimals in prose follow the language. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- hack/i18n/lib.py | 32 +++++++++++++++++++++++------- hack/i18n/prompts/translate.md | 10 +--------- hack/i18n/style-guides/pt-br.md | 2 +- hack/i18n/test_i18n.py | 35 +++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/hack/i18n/lib.py b/hack/i18n/lib.py index b30b1552..53bb9d28 100644 --- a/hack/i18n/lib.py +++ b/hack/i18n/lib.py @@ -592,9 +592,15 @@ def _prose_only(text: str) -> str: return _CODEISH_RE.sub(" ", text) -# Version-ish tokens: v1.5, 1.2.3, v1.2.5. Localizing the separator (1,2,3) or +# Version tokens: v1.5, v1.2.5, 1.2.3. Localizing the separator (1,2,3) or # bumping a digit changes documented behaviour, so counts must match the source. -_VERSION_RE = re.compile(r"\bv?\d+\.\d+(?:\.\d+)?\b") +# Only UNAMBIGUOUS shapes are enforced — a `v` prefix or three components. A +# bare `2.5` in prose is usually a quantity, and the style guides mandate the +# decimal comma for those ("0.5 vCPU" → "0,5 vCPU"); enforcing byte-for-byte +# survival there would flag every correctly localized number, refire the same +# finding every revise round, and permanently block the gate. Bare two-part +# decimals are the reviewers' job. +_VERSION_RE = re.compile(r"\bv\d+\.\d+(?:\.\d+)?\b|\b\d+\.\d+\.\d+\b") # Bare long CLI flags in prose. _FLAG_RE = re.compile(r"(?<![\w-])--[a-z][a-z0-9-]{2,}\b") @@ -633,11 +639,16 @@ def _counts(rx, s): f"the source — do not invent versions or flags"}) for term in (dnt_terms or []): - n = src.count(term) - if n and tr.count(term) < n: + # Word-boundary matching, not substring: `Go` must not count inside + # `Google`. `(?!\w)` still allows hyphenated compounds ("Cozystack- + # Plattform" is how German writes it) and possessives. + term_rx = re.compile(rf"(?<!\w){re.escape(term)}(?!\w)") + n = len(term_rx.findall(src)) + got = len(term_rx.findall(tr)) + if n and got < n: out.append({"severity": "major", "from": who, "issue": f"do-not-translate term '{term}' appears {n}× in the source " - f"but only {tr.count(term)}× in the translation — it must be " + f"but only {got}× in the translation — it must be " f"kept verbatim, not translated or transliterated"}) return out @@ -658,8 +669,15 @@ def _counts(rx, s): (r'[“][^\n]{0,80}[”]', 'English curly quotes in German prose — use „…“'), ], "es": [ - (r'(?<![¿])\b[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}\?', 'question without an opening ¿'), - (r'(?<![¡])\b[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}!', 'exclamation without an opening ¡'), + # Anchored to sentence START (line start or after end punctuation), not + # to any capitalized word — otherwise every brand name inside a + # correctly opened «¿…?» re-matches from the brand and flags correct + # text. A properly opened sentence begins with ¿/¡, which the anchor + # classes exclude. + (r'(?m)(?:^|(?<=[.!?:] ))[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}\?', + 'question without an opening ¿'), + (r'(?m)(?:^|(?<=[.!?:] ))[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}!', + 'exclamation without an opening ¡'), ], "pt-br": [ (r'\b(ficheiro|utilizador|ecrã|rato|autocarro|telemóvel|casa de banho)\b', diff --git a/hack/i18n/prompts/translate.md b/hack/i18n/prompts/translate.md index d7e80a0f..d6f10939 100644 --- a/hack/i18n/prompts/translate.md +++ b/hack/i18n/prompts/translate.md @@ -24,15 +24,7 @@ reads well in {{LANGUAGE}}; when it is documentation, stay precise and faithful. 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. Never change the VALUE of a number, version, or measurement — a figure in the - translation must mean exactly what it meant in English. Version strings, - numbers inside code, YAML values, CIDRs, resource quantities (`512Mi`, - `0.5` CPU), ports, and tags are literal identifiers: reproduce them - character-for-character, never localizing their separators. - FORMATTING of numbers, dates and units in ordinary PROSE follows the style - guide below — several languages require a decimal comma, a different - thousands separator, or a different date order. Reformatting prose is - expected; changing a value, or touching a version/identifier, is not. +5. Never change the VALUE of a number, version, or measurement — a figure in the translation must mean exactly what it meant in English. Version strings, numbers inside code, YAML values, CIDRs, Kubernetes quantities (`512Mi`, `500m`), ports, and tags are literal identifiers: reproduce them character-for-character, never localizing their separators. FORMATTING of numbers, dates and units in ordinary PROSE follows the style guide below — several languages require a decimal comma, a different thousands separator, or a different date order. Reformatting prose is expected; changing a value, or touching a version/identifier, is not. ## Preferred terminology (use consistently) diff --git a/hack/i18n/style-guides/pt-br.md b/hack/i18n/style-guides/pt-br.md index d988e996..30ebf219 100644 --- a/hack/i18n/style-guides/pt-br.md +++ b/hack/i18n/style-guides/pt-br.md @@ -35,7 +35,7 @@ Brazilian Portuguese (pt-BR), never European Portuguese. Follow kubernetes.io pt ## 5. Numbers, dates, units - **Decimal comma in prose**: "3.14" → ✓ "3,14"; "0.5 vCPU of overhead" → ✓ "0,5 vCPU de overhead". Thousands separator is a period: "1,000 nodes" → ✓ "1.000 nós"; "12,500" → ✓ "12.500". -- **Never** convert inside code, CLI output, YAML values, resource quantities, CIDRs or version numbers: `v1.2.3`, `0.5`, `500m`, `10.0.0.0/8` stay exactly as written — including when quoted in prose. +- **Never** convert inside code, CLI output, YAML values, Kubernetes quantities, CIDRs or version numbers: `v1.2.3`, `500m`, `10.0.0.0/8` stay exactly as written — including when quoted in prose. A bare decimal in ordinary prose (the "0.5 vCPU" case above) follows the prose rule, not this one. - Dates: "July 24, 2026" → ✓ "24 de julho de 2026" (month lowercase); numeric 24/07/2026; ranges "de 2024 a 2026". 24-hour clock: "9 AM UTC" → ✓ "9h UTC" / "09:00 UTC". - Units: space between number and unit (10 GB, 500 ms, 2 vCPU, 4 GiB); no space before `%` (50%); keep byte/rate units in standard casing (GiB, TiB, Mbps). diff --git a/hack/i18n/test_i18n.py b/hack/i18n/test_i18n.py index 4f9b78b2..4b174891 100644 --- a/hack/i18n/test_i18n.py +++ b/hack/i18n/test_i18n.py @@ -859,6 +859,28 @@ def test_code_spans_are_exempt(self): # it must not be double-reported here. self.assertEqual(lib.integrity_findings("Run `helm install v1.5`.", "Führen Sie aus."), []) + def test_prose_decimal_reformat_is_allowed(self): + # The style guides MANDATE the decimal comma in prose; a bare two-part + # decimal is a quantity, not a version, and must not be enforced + # byte-for-byte (the finding would refire every revise round and block + # the gate forever). + self.assertEqual(lib.integrity_findings("It is 3.14 wide.", "Es ist 3,14 breit."), []) + + def test_thousands_separator_reformat_is_allowed(self): + self.assertEqual( + lib.integrity_findings("It runs 10,000 pods.", "Es betreibt 10.000 Pods."), []) + + def test_three_component_bare_version_is_still_caught(self): + f = lib.integrity_findings("Upgrade to 1.2.3 now.", "Jetzt aktualisieren.") + self.assertTrue(any(x["severity"] == "major" and "1.2.3" in x["issue"] for x in f)) + + def test_dnt_term_is_not_counted_inside_larger_words(self): + # Substring counting would see 'Go' inside 'Google' and demand a third + # occurrence the translation never had. + self.assertEqual( + lib.integrity_findings("The Go toolchain is part of Google.", + "Der Go-Toolchain gehört zu Alphabet.", ["Go"]), []) + class TestTypographyChecks(unittest.TestCase): def test_russian_ascii_quotes_flagged(self): @@ -878,6 +900,19 @@ def test_pt_pt_vocabulary_leak_flagged(self): f = lib.check_typography("Abra o ficheiro de configuração.", "pt-br") self.assertTrue(any("European Portuguese" in x["issue"] for x in f)) + def test_spanish_opened_question_with_brand_passes(self): + # The rule must anchor at sentence start; matching at any capitalized + # word would re-match from the brand inside a correctly opened «¿…?». + self.assertEqual(lib.check_typography("¿Qué es Cozystack?", "es"), []) + + def test_spanish_unopened_question_flagged(self): + f = lib.check_typography("Cómo funciona esto exactamente?", "es") + self.assertTrue(any("¿" in x["issue"] for x in f)) + + def test_spanish_unopened_question_mid_paragraph_flagged(self): + f = lib.check_typography("Listo. Cómo funciona esto exactamente?", "es") + self.assertTrue(any("¿" in x["issue"] for x in f)) + def test_devanagari_digits_flagged(self): self.assertTrue(lib.check_typography("क्लस्टर में ३ नोड हैं।", "hi")) From 8b48f16680c38433bff5b6f59c51887dbedc0f1f Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 16:01:13 +0300 Subject: [PATCH 05/14] docs(i18n): document the deterministic checks stage and the typography lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline diagram listed every gate stage except the deterministic integrity/typography checks that feed the same findings loop, and the file table had no row for lint_translations.py — a maintainer triaging a weekly PR saw findings from checks the docs said did not exist. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- hack/i18n/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hack/i18n/README.md b/hack/i18n/README.md index 87fd5d59..991ecf31 100644 --- a/hack/i18n/README.md +++ b/hack/i18n/README.md @@ -20,6 +20,10 @@ content/en/** ──▶ worklist.py (missing | stale, via source_digest) back-translate ─▶ compare vs original (meaning-drift check) │ ▼ + deterministic checks (version/flag/do-not-translate integrity, + per-language typography — lib.py, no model calls) + │ + ▼ review ×2 (native speakers of the target language) • technical editor → fluency / register / terminology • Cozystack maintainer → technical correctness vs source @@ -163,7 +167,8 @@ succeeds. A page that reproducibly fails is a signal to translate it by hand. | `prompts/review-maintainer.md` | native Cozystack-maintainer reviewer (technical correctness) | | `prompts/revise.md` | revise a translation against reviewer findings | | `style-guides/<lang>.md` | per-language tone/register conventions | -| `lib.py` | shared helpers (config, discovery, digest, front matter, protect/restore) | +| `lib.py` | shared helpers (config, discovery, digest, front matter, protect/restore, integrity + typography checks) | +| `lint_translations.py` | typography lint for already-published translations (advisory; `--strict` to gate) | | `worklist.py` | diff detector | | `translate.py` | translate + review-gate driver; also removes orphaned translations | | `run-daily.sh` | daily runner (subscription, until limit, commit + PR) | From 05161833cb0de4c37b770e77d346e1f0e902bfa4 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 16:01:14 +0300 Subject: [PATCH 06/14] ci(i18n): run the typography lint as an advisory step lint_translations.py promised enforcement on every PR but nothing invoked it. Advisory for now; flip to --strict per language once its backlog is clean. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- .github/workflows/i18n-lint.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/i18n-lint.yml b/.github/workflows/i18n-lint.yml index 4e26e5fd..bdc21098 100644 --- a/.github/workflows/i18n-lint.yml +++ b/.github/workflows/i18n-lint.yml @@ -48,3 +48,8 @@ jobs: run: | python3 -m pip install --quiet pyyaml python3 hack/i18n/test_i18n.py + - name: Typography lint on published translations + # Advisory: reports per-language typography drift (hand edits, pages + # translated before a rule existed) without failing the build. Flip to + # --strict per language once its backlog is clean. + run: python3 hack/i18n/lint_translations.py From 7fd9d279a5c1e0931e995b7612ba075e9cd66066 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 16:14:39 +0300 Subject: [PATCH 07/14] fix(i18n): stop flagging the style guides' own canonical examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three checks contradicted the ✓ forms the guides themselves give. The Spanish inverted-mark rules matched through a mid-sentence ¿/¡, so the guide's mandated 'Si el nodo falla, ¿qué pasa?' form was flagged — and a revise round would most plausibly insert the mark at sentence start, producing exactly the form the guide marks ✗. The Russian curly-quote rule counted U+201C alone, which also CLOSES the mandated nested „лапки“, so two nested pairs on one line read as an English pair. The bare three-component version branch read localized numeric dates (24.07.2026) and period-grouped thousands (10.000.000) as invented versions, demanding the source format back in violation of the guides. Stop the Spanish span at a mid-sentence mark, require the full English “…” pair for Russian, and exempt date/thousands shapes from the invented-token report. Also note the known multi-line-tag ceiling on the HTML tag mask. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- hack/i18n/lib.py | 26 +++++++++++++++++++------- hack/i18n/test_i18n.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/hack/i18n/lib.py b/hack/i18n/lib.py index 53bb9d28..68451383 100644 --- a/hack/i18n/lib.py +++ b/hack/i18n/lib.py @@ -70,7 +70,9 @@ # refdef: [id]: https://example.com _LINKDEST_RE = re.compile(r'(?<=\])\((?P<dest><[^>]*>|[^)\s]*)(?P<title>\s+"[^"]*")?\)') _AUTOLINK_RE = re.compile(r'<(?:https?|mailto):[^>\s]+>') -_REFDEF_RE = re.compile(r'(?m)^(?P<label>\[[^\]]+\]:[ \t]*)(?P<dest>\S+)') +# `(?!\^)` keeps footnote definitions (`[^1]: prose`) out: their "destination" +# is the first word of translatable prose, not a URL. +_REFDEF_RE = re.compile(r'(?m)^(?P<label>\[(?!\^)[^\]]+\]:[ \t]*)(?P<dest>\S+)') def load_config() -> dict: @@ -601,6 +603,12 @@ def _prose_only(text: str) -> str: # finding every revise round, and permanently block the gate. Bare two-part # decimals are the reviewers' job. _VERSION_RE = re.compile(r"\bv\d+\.\d+(?:\.\d+)?\b|\b\d+\.\d+\.\d+\b") +# Correctly LOCALIZED numbers that the bare three-component branch would +# misread as invented versions: numeric dates (24.07.2026 — a component has +# 4 digits) and period-grouped thousands (10.000.000). Both are formats the +# style guides mandate for translated prose, so their appearance in the +# translation alone is expected, not an invention. +_LOCALIZED_NUMBER_RE = re.compile(r"\d+\.\d+\.\d{4}|\d{1,3}(?:\.\d{3}){2,}") # Bare long CLI flags in prose. _FLAG_RE = re.compile(r"(?<![\w-])--[a-z][a-z0-9-]{2,}\b") @@ -633,7 +641,7 @@ def _counts(rx, s): f"{got}× in the translation — it must be carried over " f"unchanged"}) for tok in b: - if tok not in a: + if tok not in a and not _LOCALIZED_NUMBER_RE.fullmatch(tok): out.append({"severity": "minor", "from": who, "issue": f"{label} '{tok}' appears in the translation but not in " f"the source — do not invent versions or flags"}) @@ -659,7 +667,10 @@ def _counts(rx, s): _TYPO_RULES: dict[str, list[tuple[str, str]]] = { "ru": [ (r'"[^"\n]{1,80}"', 'ASCII double quotes in Russian prose — use «ёлочки»'), - (r'[“”][^\n]{0,80}[“”]', 'English curly quotes in Russian prose — use «ёлочки»'), + # The English pair is “…” (U+201C…U+201D). U+201C alone must not count: + # it CLOSES the nested „лапки“ the guide mandates, so a line with two + # nested pairs («…„replicas“ и „selector“…») would otherwise match. + (r'“[^\n]{0,80}”', 'English curly quotes in Russian prose — use «ёлочки»'), # A hyphen doing a dash's job — but NOT a list marker ("\n- item"), which # is why a non-space character is required immediately before it. (r'(?<=[^\s])[ ]-[ ](?=\S)', 'hyphen used as a dash — use the em dash «—»'), @@ -672,11 +683,12 @@ def _counts(rx, s): # Anchored to sentence START (line start or after end punctuation), not # to any capitalized word — otherwise every brand name inside a # correctly opened «¿…?» re-matches from the brand and flags correct - # text. A properly opened sentence begins with ¿/¡, which the anchor - # classes exclude. - (r'(?m)(?:^|(?<=[.!?:] ))[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}\?', + # text. The span also stops at a mid-sentence ¿/¡: the guide mandates + # the mark where the question actually starts («Si el nodo falla, + # ¿qué pasa?» is the ✓ form and must not be flagged). + (r'(?m)(?:^|(?<=[.!?:] ))[A-ZÁÉÍÓÚÑ][^.!?¿\n]{5,120}\?', 'question without an opening ¿'), - (r'(?m)(?:^|(?<=[.!?:] ))[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}!', + (r'(?m)(?:^|(?<=[.!?:] ))[A-ZÁÉÍÓÚÑ][^.!?¡\n]{5,120}!', 'exclamation without an opening ¡'), ], "pt-br": [ diff --git a/hack/i18n/test_i18n.py b/hack/i18n/test_i18n.py index 4b174891..38899058 100644 --- a/hack/i18n/test_i18n.py +++ b/hack/i18n/test_i18n.py @@ -881,6 +881,18 @@ def test_dnt_term_is_not_counted_inside_larger_words(self): lib.integrity_findings("The Go toolchain is part of Google.", "Der Go-Toolchain gehört zu Alphabet.", ["Go"]), []) + def test_localized_date_is_not_an_invented_version(self): + # The de guide mandates 24.07.2026 for numeric dates in prose; the bare + # three-component branch must not read it as an invented version. + self.assertEqual( + lib.integrity_findings("Released on 07/24/2026.", + "Veröffentlicht am 24.07.2026."), []) + + def test_localized_thousands_grouping_is_not_an_invented_version(self): + self.assertEqual( + lib.integrity_findings("It holds 10,000,000 records.", + "Es hält 10.000.000 Einträge."), []) + class TestTypographyChecks(unittest.TestCase): def test_russian_ascii_quotes_flagged(self): @@ -905,6 +917,25 @@ def test_spanish_opened_question_with_brand_passes(self): # word would re-match from the brand inside a correctly opened «¿…?». self.assertEqual(lib.check_typography("¿Qué es Cozystack?", "es"), []) + def test_spanish_mid_sentence_marks_pass(self): + # The es guide's own ✓ forms: the mark goes where the question or + # exclamation actually starts, which may be mid-sentence. + self.assertEqual( + lib.check_typography("Si el nodo falla, ¿qué pasa con los datos?", "es"), []) + self.assertEqual( + lib.check_typography("Listo, ¡ya tienes un clúster!", "es"), []) + + def test_russian_nested_quotes_pass(self): + # „лапки“ close with U+201C; two nested pairs on one line must not read + # as an English “…” pair. + self.assertEqual( + lib.check_typography("Задайте параметры „replicas“ и „selector“ в манифесте.", "ru"), + []) + + def test_russian_english_curly_pair_still_flagged(self): + f = lib.check_typography("Это “cluster” здесь.", "ru") + self.assertTrue(any("ёлочки" in x["issue"] for x in f)) + def test_spanish_unopened_question_flagged(self): f = lib.check_typography("Cómo funciona esto exactamente?", "es") self.assertTrue(any("¿" in x["issue"] for x in f)) From 5aebd263c3c785b502d00197c849dadf723a9d69 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 16:30:18 +0300 Subject: [PATCH 08/14] fix(i18n): keep the hand-localized drift signal alive across CI tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drift report for hand-localized pages is built on a stale source_digest, but the surrounding tooling destroyed that signal and contradicted the contract. check-i18n.sh hard-failed CI on any digest mismatch, so a drifted transcreation made every PR touching content red — and its documented fix, a wholesale update-digests, re-stamped transcreate pages too, silencing the drift report forever while the drift persisted. The report also called the refresh optional while CI treated it as a hard failure. Drifted transcreations now produce a ::warning:: instead of an error (drift is a report for a human, not a build failure), a bare update-digests skips them so the signal survives, and passing the file explicitly re-stamps one that was genuinely refreshed by hand. Report and docstring wording now match that contract. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- hack/check-i18n.sh | 62 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/hack/check-i18n.sh b/hack/check-i18n.sh index 6299046d..46d11481 100755 --- a/hack/check-i18n.sh +++ b/hack/check-i18n.sh @@ -4,9 +4,17 @@ # # Subcommands: # check (default) Run every guard; exit non-zero on any gap. -# update-digests Recompute and rewrite `source_digest` in every translated -# page from its English source. Run this after editing an -# English source or after adding/refreshing a translation. +# Hand-localized pages (l10n: transcreate) get a ::warning:: +# instead of an error when they drift: the pipeline never +# regenerates them, so drift is a report for a human, not a +# build failure. +# update-digests [file...] +# Recompute and rewrite `source_digest` in translated pages +# from their English source. Run after editing an English +# source or after adding/refreshing a translation. Without +# arguments, hand-localized pages are SKIPPED (a wholesale +# re-stamp would silence their drift report); pass the file +# explicitly to re-stamp one you refreshed by hand. # # Guards run by `check`: # 1. i18n key parity — every i18n/<lang>.toml must define exactly the same @@ -67,6 +75,14 @@ translated_digest_files() { | sort } +# A page a human localized by hand (l10n: transcreate). The pipeline never +# regenerates these, so a stale digest here is a drift REPORT, not a build +# failure — and re-stamping it wholesale would silence the report while the +# drift persists. +is_transcreate() { + grep -m1 -E '^l10n:' "$1" 2>/dev/null | grep -qE 'transcreate' +} + # Map content/<lang>/<rel> -> content/en/<rel> en_source_for() { local f="$1" @@ -129,12 +145,20 @@ check_digest_freshness() { actual="$(grep -m1 -E '^source_digest:' "$f" | sed -E 's/.*sha256:([0-9a-fA-F]+).*/\1/')" checked=$((checked + 1)) if [ "$actual" != "$expected" ]; then - rc=1 - echo "::error::stale translation: $f" - echo " English source: $src" - echo " recorded digest: sha256:${actual}" - echo " current digest: sha256:${expected}" - echo " fix: refresh the translation, then run 'hack/check-i18n.sh update-digests'" + if is_transcreate "$f"; then + # Advisory by design: a drifted transcreation is refreshed when a + # human decides to, and must not block unrelated PRs meanwhile. + echo "::warning::hand-localized page drifted from its English source: $f" + echo " refresh the transcreation by hand, then re-stamp it with:" + echo " hack/check-i18n.sh update-digests $f" + else + rc=1 + echo "::error::stale translation: $f" + echo " English source: $src" + echo " recorded digest: sha256:${actual}" + echo " current digest: sha256:${expected}" + echo " fix: refresh the translation, then run 'hack/check-i18n.sh update-digests'" + fi fi done < <(translated_digest_files) [ "$rc" -eq 0 ] && echo "translation freshness: OK ($checked translated pages match their English source)" @@ -142,9 +166,20 @@ check_digest_freshness() { } update_digests() { + # With explicit files, re-stamp exactly those — the escape hatch for a + # transcreation a human just refreshed. With no arguments, re-stamp every + # machine-translated page but SKIP hand-localized ones: their stale digest + # is the only signal that the transcreation drifted, and a wholesale + # re-stamp would silence it while the drift persists. local f - while IFS= read -r f; do + { + if [ "$#" -gt 0 ]; then printf '%s\n' "$@"; else translated_digest_files; fi + } | while IFS= read -r f; do [ -z "$f" ] && continue + if [ "$#" -eq 0 ] && is_transcreate "$f"; then + echo "skip $f — hand-localized (l10n: transcreate); refresh it by hand, then: hack/check-i18n.sh update-digests $f" + continue + fi local src expected tmp src="$(en_source_for "$f")" if [ ! -f "$src" ]; then @@ -159,7 +194,7 @@ update_digests() { ' "$f" > "$tmp" mv "$tmp" "$f" echo "updated $f -> sha256:${expected}" - done < <(translated_digest_files) + done } cmd="${1:-check}" @@ -171,10 +206,11 @@ case "$cmd" in exit "$rc" ;; update-digests) - update_digests + shift + update_digests "$@" ;; *) - echo "usage: $0 [check|update-digests]" >&2 + echo "usage: $0 [check|update-digests [file...]]" >&2 exit 2 ;; esac From 1e47c058a937071c22673b26e7477d4a4c3879ad Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 16:43:59 +0300 Subject: [PATCH 09/14] fix(i18n): treat the markdown image marker as syntax, not prose The prose filter stripped an image's destination but left the leading exclamation mark, so a heading ending in a CJK ideograph followed by a figure read as half-width punctuation after a Chinese character, and an inline image in Spanish prose read as an exclamation missing its opening mark. Both findings refire identically every revise round (the model cannot remove image syntax), so any affected page burned its full revise budget and shipped stamped -with-findings. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- hack/i18n/lib.py | 1 + hack/i18n/test_i18n.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/hack/i18n/lib.py b/hack/i18n/lib.py index 68451383..ac95c0eb 100644 --- a/hack/i18n/lib.py +++ b/hack/i18n/lib.py @@ -579,6 +579,7 @@ def has_ref_shortcode(text: str) -> bool: r"|<!--.*?-->" # HTML comments r"|<[^>\n]+>" # HTML tags with their attributes r"|\]\([^)\s]*\)" # markdown link/image destinations + r"|!\[" # image marker — its `!` is syntax, not prose r"|^\[[^\]]+\]:[ \t]*\S+", # reference-link definitions re.DOTALL | re.MULTILINE) diff --git a/hack/i18n/test_i18n.py b/hack/i18n/test_i18n.py index 38899058..fea57177 100644 --- a/hack/i18n/test_i18n.py +++ b/hack/i18n/test_i18n.py @@ -951,6 +951,16 @@ def test_code_spans_are_exempt_from_typography(self): # Straight quotes inside code are correct and must not be flagged. self.assertEqual(lib.check_typography('Запустите `echo "hi"` сейчас.', "ru"), []) + def test_image_marker_is_not_prose(self): + # The `!` of `![alt](src)` is syntax. Without stripping it, a heading + # ending in a CJK ideograph followed by an image reads as "half-width + # punctuation after a Chinese character", and an inline image in + # Spanish prose reads as an exclamation without ¡. + self.assertEqual(lib.check_typography("## 架构图\n\n![架构](/img/arch.png)", "zh-cn"), []) + self.assertEqual( + lib.check_typography("Vea la imagen ![diagrama](/img/d.png) para más detalles.", "es"), + []) + def test_unknown_language_is_a_no_op(self): self.assertEqual(lib.check_typography('Anything "here".', "xx"), []) From 18ce6aad30d18c94fb6560170b661642d7b69d36 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 16:43:59 +0300 Subject: [PATCH 10/14] fix(i18n): exit 0 from update-digests when no stamped translations exist Feeding the file list through a pipe put the enumerating grep under pipefail: on a checkout with zero stamped translations it exits 1 and silently kills the script, where the previous process-substitution form exited 0. First-language bootstrap and fresh forks hit this. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- hack/check-i18n.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hack/check-i18n.sh b/hack/check-i18n.sh index 46d11481..ac35ac49 100755 --- a/hack/check-i18n.sh +++ b/hack/check-i18n.sh @@ -173,7 +173,9 @@ update_digests() { # re-stamp would silence it while the drift persists. local f { - if [ "$#" -gt 0 ]; then printf '%s\n' "$@"; else translated_digest_files; fi + # `|| true`: with zero stamped translations the underlying grep exits 1, + # which under `set -euo pipefail` would silently kill the whole script. + if [ "$#" -gt 0 ]; then printf '%s\n' "$@"; else translated_digest_files || true; fi } | while IFS= read -r f; do [ -z "$f" ] && continue if [ "$#" -eq 0 ] && is_transcreate "$f"; then From 93be96e0e63d06bc63d800ba8fa315cc6c06288d Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 16:56:23 +0300 Subject: [PATCH 11/14] fix(i18n): keep link-title quotes out of the prose checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prose filter stripped only space-free link destinations, so a titled link's "..." survived into the typography view. The Russian quote rule then flagged it, and the revise loop, following the style guide, would localize the ASCII quotes into guillemets — an invalid CommonMark title delimiter that stops the link parsing at all. The title stays deliberately translatable in the masked text, so the fix belongs in the prose filter, not the masking. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- hack/i18n/lib.py | 5 ++++- hack/i18n/test_i18n.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/hack/i18n/lib.py b/hack/i18n/lib.py index ac95c0eb..ea5578c1 100644 --- a/hack/i18n/lib.py +++ b/hack/i18n/lib.py @@ -578,7 +578,10 @@ def has_ref_shortcode(text: str) -> bool: r"|\{\{[<%].*?[%>]\}\}" # Hugo shortcodes (params are not prose) r"|<!--.*?-->" # HTML comments r"|<[^>\n]+>" # HTML tags with their attributes - r"|\]\([^)\s]*\)" # markdown link/image destinations + # Link/image destinations, with the optional "title". The title's ASCII + # quotes are CommonMark delimiters — flagging them would send the revise + # loop to «localize» them into a form that no longer parses as a link. + r'|\]\([^)\s]*(?:\s+"[^"]*")?\)' r"|!\[" # image marker — its `!` is syntax, not prose r"|^\[[^\]]+\]:[ \t]*\S+", # reference-link definitions re.DOTALL | re.MULTILINE) diff --git a/hack/i18n/test_i18n.py b/hack/i18n/test_i18n.py index fea57177..8cb6c9ed 100644 --- a/hack/i18n/test_i18n.py +++ b/hack/i18n/test_i18n.py @@ -951,6 +951,15 @@ def test_code_spans_are_exempt_from_typography(self): # Straight quotes inside code are correct and must not be flagged. self.assertEqual(lib.check_typography('Запустите `echo "hi"` сейчас.', "ru"), []) + def test_link_title_quotes_are_not_prose(self): + # A link title's ASCII quotes are CommonMark delimiters. Flagging them + # would have the revise loop «localize» them into guillemets, breaking + # the link — the exact integrity this pipeline exists to guarantee. + self.assertEqual( + lib.check_typography( + 'Смотрите [руководство](/docs/install "Руководство по установке") здесь.', "ru"), + []) + def test_image_marker_is_not_prose(self): # The `!` of `![alt](src)` is syntax. Without stripping it, a heading # ending in a CJK ideograph followed by an image reads as "half-width From 5f0e4d242c4f4123a157a58a5a1b28544addef00 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin <f@lex.la> Date: Fri, 31 Jul 2026 17:07:17 +0300 Subject: [PATCH 12/14] fix(i18n): warn instead of claiming success when a page has no digest to update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explicit-file mode of update-digests made a new input class reachable: a hand-authored page without a source_digest line. The awk only rewrites an existing line, so such a page passed through untouched while the script still printed "updated" — and the drift report would keep listing the page forever while its printed remedy kept lying that it worked. Check for the line first and print a warning naming what is missing. Also match the transcreate marker exactly (not as a substring) and note the balanced-paren ceiling on the link-destination mask. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --- hack/check-i18n.sh | 8 +++++++- hack/i18n/lib.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/hack/check-i18n.sh b/hack/check-i18n.sh index ac35ac49..af748621 100755 --- a/hack/check-i18n.sh +++ b/hack/check-i18n.sh @@ -80,7 +80,7 @@ translated_digest_files() { # failure — and re-stamping it wholesale would silence the report while the # drift persists. is_transcreate() { - grep -m1 -E '^l10n:' "$1" 2>/dev/null | grep -qE 'transcreate' + grep -m1 -E '^l10n:' "$1" 2>/dev/null | grep -qE '^l10n:[[:space:]]*"?transcreate"?[[:space:]]*$' } # Map content/<lang>/<rel> -> content/en/<rel> @@ -188,6 +188,12 @@ update_digests() { echo "::warning::skip $f — English source missing: $src" continue fi + # The awk below only REWRITES an existing line; without this check a page + # missing the field would pass through untouched yet be reported "updated". + if ! grep -qE '^source_digest:' "$f"; then + echo "::warning::skip $f — no source_digest line in the front matter; add one, then re-run" + continue + fi expected="$(sha256 "$src")" tmp="$(mktemp)" awk -v d="sha256:${expected}" ' diff --git a/hack/i18n/lib.py b/hack/i18n/lib.py index ea5578c1..e953d858 100644 --- a/hack/i18n/lib.py +++ b/hack/i18n/lib.py @@ -68,6 +68,10 @@ # inline: [text](/docs/install "Optional title") -> destination only # autolink: <https://example.com> # refdef: [id]: https://example.com +# Known ceiling: a destination with unescaped parens (`/wiki/Foo_(bar)`) masks +# only up to the first `)`, leaving a stray paren exposed to the model. Restore +# still round-trips; the risk is the model "tidying" the stray paren. No +# in-scope page has one; extend to balanced-paren matching if that changes. _LINKDEST_RE = re.compile(r'(?<=\])\((?P<dest><[^>]*>|[^)\s]*)(?P<title>\s+"[^"]*")?\)') _AUTOLINK_RE = re.compile(r'<(?:https?|mailto):[^>\s]+>') # `(?!\^)` keeps footnote definitions (`[^1]: prose`) out: their "destination" From a41be36b691c8ba2d5cbfcda159e4c4cd5bdd701 Mon Sep 17 00:00:00 2001 From: tym83 <6355522@gmail.com> Date: Mon, 10 Aug 2026 13:03:06 +0500 Subject: [PATCH 13/14] fix(i18n): reject placeholder tokens the model was never sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-1 fix that stopped requiring inner tokens of a nested stash also stopped forbidding them: a reply carrying a legit outer token plus a hallucinated inner token passed the guard, reverse restore expanded both, and the page shipped the nested content twice — silent duplication the residual check can no longer see. The guard now expects a sent token exactly once and an unsent token zero times. README documents that l10n: transcreate drives the pipeline, not just review triage. Signed-off-by: tym83 <6355522@gmail.com> --- hack/i18n/README.md | 8 +++++++- hack/i18n/test_i18n.py | 14 ++++++++++++++ hack/i18n/translate.py | 24 ++++++++++++++---------- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/hack/i18n/README.md b/hack/i18n/README.md index 991ecf31..38a7c8a8 100644 --- a/hack/i18n/README.md +++ b/hack/i18n/README.md @@ -189,9 +189,15 @@ succeeds. A page that reproducibly fails is a signal to translate it by hand. | Field | Meaning | Read by | |-------|---------|---------| | `source_digest` | sha256 of the English source the translation was made from — the freshness signal | `hack/check-i18n.sh`, `worklist.py` | -| `l10n` | *how* the page was localized: `mt` (machine) or `transcreate` | `build_worklist`, humans triaging review | +| `l10n` | *how* the page was localized: `mt` (machine) or `transcreate` | `build_worklist`, `translate.py`, `hack/check-i18n.sh`, plus humans triaging review | | `translation_review` | *ratification state*: `auto-reviewed`, `auto-reviewed-with-findings`, or `ratified` | `translation-banner.html` | +`l10n: transcreate` is load-bearing for the pipeline, not just a review hint: +mark a page `transcreate` → `worklist.py` skips it and `translate.py` refuses to +overwrite it → `check-i18n.sh` downgrades its source drift to a warning instead +of failing CI → you refresh the wording by hand → run `update-digests <file>` to +re-stamp `source_digest` so the warning clears. + `translation_status: current` appears on pages from the i18n PoC. It is not written or read by anything — `source_digest` computes freshness properly — so this pipeline does not write it. diff --git a/hack/i18n/test_i18n.py b/hack/i18n/test_i18n.py index 8cb6c9ed..73490ac2 100644 --- a/hack/i18n/test_i18n.py +++ b/hack/i18n/test_i18n.py @@ -597,6 +597,20 @@ def test_nested_placeholder_validates_only_top_level(self): f'===FRONTMATTER===\n{{}}\n===BODY===\ntranslated {masked}', store, set()) self.assertIn('{{< version-pin "cozystack_version" >}}', body) + def test_inner_token_the_model_never_saw_is_rejected(self): + # The mirror of the case above: the model may OMIT an inner token, but + # it must never EMIT one. A reply carrying the legit outer token plus a + # hallucinated inner token would otherwise restore the nested content + # twice — silent duplication the residual check can no longer catch. + masked, store = lib.protect("Use `{{< param version >}}` here.") + outer = next(t for t in store if t in masked) + inner = next(t for t in store if t not in masked) + with self.assertRaises(translate.ProtocolError) as cm: + translate._split_payload_response( + f'===FRONTMATTER===\n{{}}\n===BODY===\n{outer} и {inner}', + store, set()) + self.assertIn("injected", str(cm.exception)) + class TestFindingsReport(unittest.TestCase): def test_report_names_the_page_and_every_finding(self): diff --git a/hack/i18n/translate.py b/hack/i18n/translate.py index bce32152..da77bdd5 100644 --- a/hack/i18n/translate.py +++ b/hack/i18n/translate.py @@ -214,21 +214,25 @@ def _split_payload_response(out: str, store: dict, expect: set[str]) -> tuple[di else: tr_fm = {} body = body.strip() - # Only TOP-LEVEL placeholders are sent to the model and must survive verbatim. - # A nested token (e.g. an SC shortcode stashed inside an INLINECODE span — - # `` `{{< version-pin "x" >}}` ``) lives inside another entry's stored value, - # never appears in the masked text the model sees, and is unwound transitively - # by restore()'s fixed-point loop. Counting it here would report 0× and raise - # on every page that nests, making such pages permanently untranslatable. + # A token the model was actually SENT must survive exactly once; a token it was + # never sent must not appear at all. A token is sent iff it is TOP-LEVEL — not + # contained in another entry's stored value. A nested token (e.g. an SC shortcode + # stashed inside an INLINECODE span — `` `{{< version-pin "x" >}}` ``) never + # appears in the masked text the model sees and is unwound transitively by + # restore()'s fixed-point loop, so it must be 0× here: if it shows up the model + # injected it, and reverse restore would expand it into duplicated content the + # residual check can no longer see. top_level = {tok for tok in store if not any(tok in val for other, val in store.items() if other != tok)} - bad = {tok: body.count(tok) for tok in top_level if body.count(tok) != 1} + bad = {tok: body.count(tok) for tok in store + if body.count(tok) != (1 if tok in top_level else 0)} if bad: tok, n = next(iter(bad.items())) - what = "lost" if n == 0 else "duplicated" + expected = 1 if tok in top_level else 0 + what = "injected" if expected == 0 else "lost" if n == 0 else "duplicated" raise ProtocolError(f"{len(bad)} protected placeholder(s) {what} by the model " - f"(e.g. {tok} appears {n}×, expected 1) — refusing to write a page " - f"with dropped or duplicated code") + f"(e.g. {tok} appears {n}×, expected {expected}) — refusing to write a " + f"page with dropped, duplicated or injected code") return tr_fm, lib.restore(body, store) From 1e44e855c5ab100169a14e6a7c0d0e3dbf4be120 Mon Sep 17 00:00:00 2001 From: tym83 <6355522@gmail.com> Date: Tue, 18 Aug 2026 16:38:57 +0600 Subject: [PATCH 14/14] =?UTF-8?q?fix(i18n):=20scope-aware=20freshness=20li?= =?UTF-8?q?nt=20=E2=80=94=20superseded=20docs=20versions=20are=20advisory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-i18n.sh no longer fails CI on a machine-translated page of a non-latest docs version. The pipeline only refreshes the latest version and removes superseded translations on its next run, so a drifted digest there is not something a PR can fix by rerunning — failing the lint blocked every unrelated PR in the repo once the upstream tags workflow pushed an old-version update. Such pages now emit a ::warning:: (like transcreations) instead of a blocking ::error::; latest-version pages remain hard errors. Signed-off-by: tym83 <6355522@gmail.com> --- hack/check-i18n.sh | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/hack/check-i18n.sh b/hack/check-i18n.sh index af748621..e80bd994 100755 --- a/hack/check-i18n.sh +++ b/hack/check-i18n.sh @@ -91,6 +91,37 @@ en_source_for() { echo "$CONTENT_DIR/$DEFAULT_LANG/$rel" } +# The latest docs version (params.latest_version_id in hugo.yaml). The pipeline +# only ever refreshes the latest version, so a stale digest on an older, +# noindex'd version is not something a PR can fix by rerunning. Read once and +# cached. lib.py fails closed on a missing key; here we degrade to an empty +# value, which turns is_superseded_docs into a no-op (nothing is treated as +# superseded) rather than silencing a genuine error. +LATEST_DOCS="" +latest_docs_version() { + local hugo; hugo="$(dirname "$CONTENT_DIR")/hugo.yaml" + [ -f "$hugo" ] || return 0 + grep -m1 -E '^[[:space:]]*latest_version_id:' "$hugo" \ + | sed -E 's/.*latest_version_id:[[:space:]]*"?([^"[:space:]]+)"?.*/\1/' +} + +# True when f is a translated docs page of a NON-latest version +# (content/<lang>/docs/<ver>/... with ver != latest). Such a page is out of the +# pipeline's scope: it is never refreshed and is removed by the next pipeline +# run (find_orphan_translations), so a drifted digest on it must be advisory, +# not a CI blocker for every unrelated PR in the repo. +is_superseded_docs() { + [ -n "$LATEST_DOCS" ] || return 1 + local rel="${1#"$CONTENT_DIR"/}"; rel="${rel#*/}" # <rel> under the lang + case "$rel" in + docs/*/*) + local ver="${rel#docs/}"; ver="${ver%%/*}" + [ "$ver" != "$LATEST_DOCS" ] + ;; + *) return 1 ;; + esac +} + check_key_parity() { local ref="$I18N_DIR/$DEFAULT_LANG.toml" local rc=0 @@ -132,6 +163,7 @@ check_digest_freshness() { local rc=0 local checked=0 local f + LATEST_DOCS="$(latest_docs_version)" while IFS= read -r f; do [ -z "$f" ] && continue local src expected actual @@ -151,6 +183,13 @@ check_digest_freshness() { echo "::warning::hand-localized page drifted from its English source: $f" echo " refresh the transcreation by hand, then re-stamp it with:" echo " hack/check-i18n.sh update-digests $f" + elif is_superseded_docs "$f"; then + # Out of the pipeline's scope: the latest version is $LATEST_DOCS, the + # pipeline never refreshes older versions, and its next run removes this + # page as a superseded orphan. Failing the lint here would block every + # unrelated PR on a page no PR can fix by rerunning the pipeline. + echo "::warning::stale translation of a superseded docs version (latest is $LATEST_DOCS): $f" + echo " the pipeline removes superseded translations on its next run; not a blocker." else rc=1 echo "::error::stale translation: $f"