diff --git a/.claude/skills/translate-page/SKILL.md b/.claude/skills/translate-page/SKILL.md new file mode 100644 index 0000000..58fe9b0 --- /dev/null +++ b/.claude/skills/translate-page/SKILL.md @@ -0,0 +1,129 @@ +--- +name: translate-page +description: Translate a documentation page from docs/en/ into another language and stamp it. Use when asked to translate a page, add a language, or bring a stale translation up to date in this repo. +--- + +# Translate a documentation page + +Translation here is a repeatable operation, not an ad-hoc prompt. The rules below +exist because each of them was broken once and cost real work. + +## Inputs + +- A page path under `docs/en/`, or a page reported by + `uv run python scripts/translation_status.py` as `missing` or `stale`. +- A target language directory, e.g. `docs/fi/`. + +## Before translating + +1. **Read the glossary for the target language** — + `solutions/translation/finnish-glossary.md` for Finnish, and its equivalent + for other languages. It fixes terminology, unit formatting, address form and + what stays in English. Follow it exactly. +2. If the page introduces a term the glossary does not cover, **add it to the + glossary** in the same change. Do not invent a one-off translation: the whole + point is that the same English term reads the same way on every page. +3. If the page is `stale` rather than `missing`, read the English diff the + status report prints. Translate the change, not the whole page. + +## Translating + +The translation lives at the mirrored path — `docs/en/user-guide/hardware.md` +becomes `docs/fi/user-guide/hardware.md`. Only markdown goes under the language +directory; images stay with the English source and are shared. + +**Preserve structure exactly.** Same headings, list items, numbered steps, +images, admonitions, table rows, footnotes and code fences, in the same order. + +**Never touch:** + +- Code fences and their contents, including comments inside them +- Inline code: commands, file paths, hostnames, config keys +- UI strings the reader will see on their own screen in English +- Product, protocol and hardware names +- Image filenames and paths + +**Always convert:** units to SI spacing and decimal comma (`0.9A` → `0,9 A`, +`5.5 x 2.1 mm` → `5,5 × 2,1 mm`). This is not optional formatting; it is the +correct way to write the value. + +**Two markdown traps** that neither `--strict` nor GitHub's preview catches — +both are documented in `solutions/best-practices/`: + +- A blank line before the first item of a list +- Four spaces, not three, for a sub-list under a numbered step + +**Never write an `en/` or `fi/` segment into a path inside a page.** The +language comes from which directory the file lives in. + +## Anchors + +Anchors derive from heading text, so translating a heading changes its slug. +Slugs strip diacritics and lowercase: `Mikä HALPI2 on?` → `mika-halpi2-on`. + +Two distinct jobs: + +1. **Inside the page you are translating** — rewrite every `](#…)` to the + translated heading's slug. +2. **In pages you are not touching** — a link like + `](./operation.md#status-led-indicators)` in an already-translated page keeps + working until `operation.md` is translated, and breaks the moment it is. This + is a delayed fault. After translating, run the anchor check across the whole + built site, not just your page. + +Do not guess slugs. Build, then read the real ids out of the generated HTML. + +## Stamping + +The stamp records the git blob hash of the English source the translation was +written against. Write it with the helper, never by hand: + +```bash +uv run python scripts/stamp_translation.py docs/fi/user-guide/hardware.md +``` + +**Stamp only when you have actually translated.** A stamp updated without real +translation work reports green and makes the staleness invisible — that is the +one failure the status check cannot detect, and this skill is where the +discipline lives. If you touched only the target language (fixing wording, +fixing a typo), the English source did not change: leave the stamp alone. + +## Verifying + +All four, every time: + +```bash +uv run mkdocs build --strict +uv run python scripts/check_anchors.py site +uv run python scripts/translation_status.py +``` + +and a structure comparison against the source: + +```bash +python3 - <<'PY' +import re +en = 'docs/en/user-guide/hardware.md'; fi = 'docs/fi/user-guide/hardware.md' +def stats(p): + t = re.sub(r'^---\n.*?\n---\n', '', open(p, encoding='utf-8').read(), flags=re.S) + return {k: len(re.findall(v, t, re.M)) for k, v in { + 'headings': r'^#{1,6} ', 'bullets': r'^\s*[-*] ', 'numbered': r'^\s*\d+\. ', + 'images': r'!\[', 'admonitions': r'^!!! ', 'table rows': r'^\|', + 'fences': r'^```'}.items()} +a, b = stats(en), stats(fi) +print(a); print(b); print('match' if a == b else 'MISMATCH') +PY +``` + +A mismatch means content was dropped or merged. Find it before committing. + +Finally, confirm no numeric value drifted: every number in the English text +should appear in the translation, unless it was deliberately spelled out as a +word. A wrong voltage or current in an installation guide is a safety problem, +not a typo. + +## Committing + +One commit per logical group of pages. If pages cross-link each other, translate +and commit them together — otherwise the intermediate commit has links pointing +at headings that do not exist yet. diff --git a/.github/workflows/translation-status.yml b/.github/workflows/translation-status.yml new file mode 100644 index 0000000..e21dd3b --- /dev/null +++ b/.github/workflows/translation-status.yml @@ -0,0 +1,95 @@ +name: Translation Status + +on: + pull_request: + paths: + - 'docs/**' + - 'mkdocs.yml' + - 'scripts/**' + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: translation-status-${{ github.ref }} + cancel-in-progress: true + +jobs: + status: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full history: the report resolves the stamped blob to show the + # English diff since a translation was written. + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v5 + - run: uv sync + + - name: Report translation status + run: | + # tee, not plain redirection: a report only in the job summary is + # invisible in the logs, which is where you look when it misbehaves. + uv run python scripts/translation_status.py --format markdown --diff \ + | tee report.md + cat report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Comment on the pull request + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.number }} + run: | + # Only the English pages this PR actually touches. Which paths a PR + # touched is a fact, so a PR editing only translations says nothing. + pages=$(git diff --name-only \ + "origin/${{ github.base_ref }}...HEAD" -- 'docs/en/**/*.md' \ + | sed 's|^docs/en/||') + if [ -z "$pages" ]; then + echo "No English pages touched; nothing to report." + exit 0 + fi + + # shellcheck disable=SC2086 + uv run python scripts/translation_status.py \ + --format markdown --diff --only-pages $pages > comment.md + printf '\n\n' >> comment.md + + existing=$(gh api "repos/${{ github.repository }}/issues/$PR/comments" \ + --jq 'map(select(.body | contains(""))) | .[0].id // empty') + if [ -n "$existing" ]; then + gh api "repos/${{ github.repository }}/issues/comments/$existing" \ + -X PATCH -F body=@comment.md --silent + echo "Updated comment $existing" + else + gh api "repos/${{ github.repository }}/issues/$PR/comments" \ + -F body=@comment.md --silent + echo "Created comment" + fi + + # Last, because unlike a stale translation a broken anchor is actual + # breakage and fails the run — and the report above must still be + # published when it does. + - name: Check anchors + run: | + uv run mkdocs build --strict + # PIPESTATUS, not $?: piping into tee would otherwise mask the + # checker's exit status behind tee's. + set +e + uv run python scripts/check_anchors.py site | tee anchors.txt + broken=${PIPESTATUS[0]} + set -e + { + echo "" + echo "## Anchor check" + echo "" + echo '```' + cat anchors.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$broken" diff --git a/.gitignore b/.gitignore index e7df7e7..0dd717a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ site/ .venv/ +__pycache__/ diff --git a/docs/fi/appendices/compliance.md b/docs/fi/appendices/compliance.md index 5b0536e..5f7f0eb 100644 --- a/docs/fi/appendices/compliance.md +++ b/docs/fi/appendices/compliance.md @@ -1,3 +1,7 @@ +--- +translated_from: 75cd8c1d6a07b1e062e9cdb4a082d31038fc622c +--- + # Vaatimustenmukaisuus ja sertifioinnit - CE-merkintä ja vaatimustenmukaisuusvakuutukset diff --git a/docs/fi/appendices/design-files.md b/docs/fi/appendices/design-files.md index 9119379..be54d76 100644 --- a/docs/fi/appendices/design-files.md +++ b/docs/fi/appendices/design-files.md @@ -1,3 +1,7 @@ +--- +translated_from: fc7ea79249b080c0f717303d066b9f6ea6d64795 +--- + # Suunnittelutiedostot ja kytkentäkaaviot Tällä sivulla ovat HALPI2:n kytkentäkaaviot ja mekaniikkasuunnittelun tiedostot. diff --git a/docs/fi/appendices/errata.md b/docs/fi/appendices/errata.md index d2edbae..7e43a57 100644 --- a/docs/fi/appendices/errata.md +++ b/docs/fi/appendices/errata.md @@ -1,3 +1,7 @@ +--- +translated_from: 930b506809e4abe2b54e4fea058658a9d6d94461 +--- + # Tunnetut virheet Tällä sivulla on lueteltu eri HALPI2-versioiden tunnetut laitteisto-ongelmat. diff --git a/docs/fi/appendices/resources.md b/docs/fi/appendices/resources.md index 72c8d8d..cc59c63 100644 --- a/docs/fi/appendices/resources.md +++ b/docs/fi/appendices/resources.md @@ -1,3 +1,7 @@ +--- +translated_from: 991d6882660454e7737c452f574d858ffdeb3b93 +--- + # Lisätietoja - Keskustelupalstat ja tuki diff --git a/docs/fi/faq.md b/docs/fi/faq.md index 13e613f..7f3fa9e 100644 --- a/docs/fi/faq.md +++ b/docs/fi/faq.md @@ -1 +1,5 @@ +--- +translated_from: 4514b4c10652208edff9229b29c132b6f61399f0 +--- + # UKK diff --git a/docs/fi/getting-started/getting-started.md b/docs/fi/getting-started/getting-started.md index 7fe079b..aca7931 100644 --- a/docs/fi/getting-started/getting-started.md +++ b/docs/fi/getting-started/getting-started.md @@ -1,3 +1,7 @@ +--- +translated_from: a51e1cfe53d070c073a563641f9301fd3383a418 +--- + # Aloitusopas Tämä opas saa HALPI2:n toimintaan alle 30 minuutissa ja käsittelee myös kiinteän asennuksen. Seuraa vaiheita järjestyksessä: aloita pöytäkokoonpanolla ja varmista että kaikki toimii, ja siirry vasta sitten kiinteään asennukseen. diff --git a/docs/fi/index.md b/docs/fi/index.md index de77678..d9ee7ba 100644 --- a/docs/fi/index.md +++ b/docs/fi/index.md @@ -1,3 +1,7 @@ +--- +translated_from: e4d4a4c5108676be9c19bdd2a82a321b24b14191 +--- + # Johdanto HALPI2 on käyttövalmis venetietokone, joka perustuu Raspberry Pi Compute Module 5 -moduuliin (CM5). Siinä on kattava valikoima ominaisuuksia, jotka sopivat hyvin vene-, ajoneuvo- ja moniin teollisuussovelluksiin. diff --git a/docs/fi/software-development/advanced-config.md b/docs/fi/software-development/advanced-config.md index 1bb87ec..b10ed6a 100644 --- a/docs/fi/software-development/advanced-config.md +++ b/docs/fi/software-development/advanced-config.md @@ -1,3 +1,7 @@ +--- +translated_from: 7cd96fcdbd05d13cf6d7a0aece5e788de8ca9c62 +--- + # Lisäasetukset - Suorituskyvyn viritys diff --git a/docs/fi/software-development/daemon.md b/docs/fi/software-development/daemon.md index 9ab4936..eaf25cc 100644 --- a/docs/fi/software-development/daemon.md +++ b/docs/fi/software-development/daemon.md @@ -1,3 +1,7 @@ +--- +translated_from: 4312a0e2c31bc8816de0a9735b84742671efda43 +--- + # HALPI2-daemon - Asennus ja asetukset diff --git a/docs/fi/software-development/integration.md b/docs/fi/software-development/integration.md index 74ec762..630acc0 100644 --- a/docs/fi/software-development/integration.md +++ b/docs/fi/software-development/integration.md @@ -1,3 +1,7 @@ +--- +translated_from: 0ef15a1b16d45ab5bbb343c19900513802350f96 +--- + # Järjestelmäintegraatio - Device tree -overlayt diff --git a/docs/fi/software-development/ubuntu-installation.md b/docs/fi/software-development/ubuntu-installation.md index eddb64f..d28719e 100644 --- a/docs/fi/software-development/ubuntu-installation.md +++ b/docs/fi/software-development/ubuntu-installation.md @@ -1,3 +1,7 @@ +--- +translated_from: 8c2d1560ad56e730c3fe6476cd5cf075b632b539 +--- + # Muiden Debian-pohjaisten jakeluiden käyttö !!! warning "Huomio" diff --git a/docs/fi/technical-reference/controller.md b/docs/fi/technical-reference/controller.md index 9595657..a59c65c 100644 --- a/docs/fi/technical-reference/controller.md +++ b/docs/fi/technical-reference/controller.md @@ -1,3 +1,7 @@ +--- +translated_from: c10fce7935a4a3da34e1ce003c5a924eed68b8be +--- + # Emolevyn ohjain - RP2040-firmwaren toiminnot diff --git a/docs/fi/technical-reference/hardware.md b/docs/fi/technical-reference/hardware.md index e0d9b72..4d8a2fd 100644 --- a/docs/fi/technical-reference/hardware.md +++ b/docs/fi/technical-reference/hardware.md @@ -1,3 +1,7 @@ +--- +translated_from: c237d8b6a74b99528445a8bb38aa5473b824b52e +--- + # Laitteiston tekniset tiedot Tällä sivulla ovat HALPI2:n sähköiset, mekaaniset ja ympäristöä koskevat tekniset tiedot. Toimintaohjeet (asennus, huolto, osien vaihto) löytyvät [Laitteisto-oppaasta](../user-guide/hardware.md). Liitäntöjen protokollatiedot ovat sivulla [Liitännät ja tiedonsiirto](./interfaces.md). diff --git a/docs/fi/technical-reference/interfaces.md b/docs/fi/technical-reference/interfaces.md index 847302e..2fb08c7 100644 --- a/docs/fi/technical-reference/interfaces.md +++ b/docs/fi/technical-reference/interfaces.md @@ -1,3 +1,7 @@ +--- +translated_from: 9497de10027831b20a1e2278a32df0c12d9a4a39 +--- + # Liitännät ja tiedonsiirto Tällä sivulla kuvataan, miten CM5:n liitännät on tuotu HALPI2:n emolevylle. diff --git a/docs/fi/technical-reference/power-supply.md b/docs/fi/technical-reference/power-supply.md index 1154f5f..3c9fcc8 100644 --- a/docs/fi/technical-reference/power-supply.md +++ b/docs/fi/technical-reference/power-supply.md @@ -1,3 +1,7 @@ +--- +translated_from: 5229ab5363e54a19e4330c30051e4787ece5806e +--- + # Virransyöttö tarkemmin - Syöttöjännitealueet ja suojaus diff --git a/docs/fi/user-guide/hardware.md b/docs/fi/user-guide/hardware.md index e4f61f1..7389c99 100644 --- a/docs/fi/user-guide/hardware.md +++ b/docs/fi/user-guide/hardware.md @@ -1,3 +1,7 @@ +--- +translated_from: 9741366021074655d667fcf3a93a634f86f3519a +--- + # Laitteisto-opas ## Kotelon käsittely diff --git a/docs/fi/user-guide/interfaces.md b/docs/fi/user-guide/interfaces.md index dc8c23b..e5cd1d1 100644 --- a/docs/fi/user-guide/interfaces.md +++ b/docs/fi/user-guide/interfaces.md @@ -1,3 +1,7 @@ +--- +translated_from: da8aa35c462e57bc7c0b00d50046a1df518e97dd +--- + # Liitännät ja tiedonsiirto ## CAN FD / NMEA 2000 diff --git a/docs/fi/user-guide/operation.md b/docs/fi/user-guide/operation.md index d9dba62..0b611a3 100644 --- a/docs/fi/user-guide/operation.md +++ b/docs/fi/user-guide/operation.md @@ -1,3 +1,7 @@ +--- +translated_from: 3ad6bd291105f72d9e440ca46e96fe9fa085e02c +--- + # Järjestelmän käyttö ## Tila-LEDit diff --git a/docs/fi/user-guide/software.md b/docs/fi/user-guide/software.md index 283fbf0..5be1857 100644 --- a/docs/fi/user-guide/software.md +++ b/docs/fi/user-guide/software.md @@ -1,3 +1,7 @@ +--- +translated_from: a428b6a7e1ca303e0571592a86d0cc6a3db97a83 +--- + # Ohjelmisto-opas ## Käyttöjärjestelmän levykuvat diff --git a/docs/fi/user-guide/troubleshooting.md b/docs/fi/user-guide/troubleshooting.md index 8017509..96c9bcf 100644 --- a/docs/fi/user-guide/troubleshooting.md +++ b/docs/fi/user-guide/troubleshooting.md @@ -1,3 +1,7 @@ +--- +translated_from: 35a84e7f96c0891201c8a8248bf146139684b772 +--- + # Vianetsintä Tällä sivulla käydään läpi tavallisia HALPI2:n käytössä vastaan tulevia ongelmia ja niiden ratkaisut. diff --git a/docs/fi/user-guide/use-cases.md b/docs/fi/user-guide/use-cases.md index b0bcae5..e164217 100644 --- a/docs/fi/user-guide/use-cases.md +++ b/docs/fi/user-guide/use-cases.md @@ -1,3 +1,7 @@ +--- +translated_from: 347076aa60c0c593af503f8af30bc480108964b8 +--- + # Yleiset käyttötapaukset - Navigointijärjestelmän pystytys veneeseen diff --git a/scripts/check_anchors.py b/scripts/check_anchors.py new file mode 100644 index 0000000..8ad9591 --- /dev/null +++ b/scripts/check_anchors.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Verify that every internal anchor in the built site resolves to a real id. + +Anchors are generated from heading text, so translating a heading changes its +slug and silently breaks every link pointing at it — including links on pages +that were not touched, which is why this is a delayed fault: a cross-page anchor +keeps working until its *target* page is translated. `mkdocs build --strict` +does not validate anchors at all. + +Run against a built site directory. Exit status is 1 if any anchor is broken. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from urllib.parse import unquote, urldefrag + +HREF = re.compile(r'href="([^"]+)"') +ID = re.compile(r'\sid="([^"]+)"') + + +def collect_pages(site: str) -> dict[str, set[str]]: + """Map each built page to the set of element ids it defines.""" + ids: dict[str, set[str]] = {} + for root, _, files in os.walk(site): + for name in files: + if name.endswith(".html"): + path = os.path.join(root, name) + text = open(path, encoding="utf-8").read() + ids[os.path.realpath(path)] = set(ID.findall(text)) + return ids + + +def resolve(href: str, page: str, site: str, base: str) -> str | None: + """Resolve an href to the built file it points at, or None if not ours.""" + target, _ = urldefrag(href) + target = unquote(target) + if not target: + return os.path.realpath(page) + if target.startswith("/"): + if not target.startswith(base): + return None + path = os.path.normpath(os.path.join(site, target[len(base):])) + else: + path = os.path.normpath(os.path.join(os.path.dirname(page), target)) + if not path.endswith(".html"): + path = os.path.join(path, "index.html") + return os.path.realpath(path) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("site", nargs="?", default="site") + parser.add_argument("--base", default="/halpi2/", + help="path component of site_url, for root-absolute links") + args = parser.parse_args() + + ids = collect_pages(args.site) + if not ids: + # Passing on an empty site would be a false green: the build produced + # nothing, or the path is wrong, and neither is "all anchors resolve". + print(f"No built pages found under {args.site!r} — nothing to check.", + file=sys.stderr) + return 2 + + broken: list[tuple[str, str, str]] = [] + checked = 0 + + for page in sorted(ids): + for href in HREF.findall(open(page, encoding="utf-8").read()): + if href.startswith(("http://", "https://", "mailto:", "data:")): + continue + _, fragment = urldefrag(href) + if not fragment: + continue + target = resolve(href, page, args.site, args.base) + if target is None: + continue + checked += 1 + relative = os.path.relpath(page, args.site) + if target not in ids: + broken.append((relative, href, "target page does not exist")) + elif unquote(fragment) not in ids[target]: + broken.append((relative, href, "no such anchor on the target page")) + + print(f"Checked {checked} anchor links across {len(ids)} pages.") + if broken: + print(f"\n{len(broken)} broken:\n") + for page, href, why in broken: + print(f" {page}\n -> {href} ({why})") + return 1 + print("All anchors resolve.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/stamp_translation.py b/scripts/stamp_translation.py new file mode 100644 index 0000000..61fc83c --- /dev/null +++ b/scripts/stamp_translation.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Write the translated_from stamp into a translation's frontmatter. + +Stamp a translation only when it has actually been (re-)translated against the +current English source. A stamp updated without real translation work reports +green and makes the staleness invisible — that is the one gap the status check +cannot close. + + uv run python scripts/stamp_translation.py docs/fi/user-guide/hardware.md +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from translation_status import configured_languages + +DOCS = Path("docs") +STAMP_KEY = "translated_from" + + +def english_source(translation: Path, default: str) -> Path: + """docs// -> docs//.""" + parts = translation.parts + if len(parts) < 3 or parts[0] != DOCS.name: + raise SystemExit(f"{translation}: not a path under docs//") + if parts[1] == default: + raise SystemExit( + f"{translation}: this is a source page, not a translation. " + f"Source pages carry no stamp — that is the point: an English edit " + f"needs no ceremony." + ) + return DOCS / default / Path(*parts[2:]) + + +def blob_hash(path: Path) -> str: + return subprocess.run( + ["git", "hash-object", str(path)], + capture_output=True, text=True, check=True, + ).stdout.strip() + + +def restamp(text: str, value: str) -> str: + """Set the stamp, replacing an existing one and preserving other keys.""" + line = f"{STAMP_KEY}: {value}" + if not text.startswith("---\n"): + return f"---\n{line}\n---\n\n{text}" + end = text.find("\n---", 4) + if end == -1: + raise SystemExit("frontmatter is not terminated") + front, body = text[4:end], text[end + 4:].lstrip("\n") + kept = [l for l in front.splitlines() if not l.startswith(f"{STAMP_KEY}:")] + return "---\n" + "\n".join([*kept, line]) + "\n---\n\n" + body + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("translations", nargs="+", type=Path) + args = parser.parse_args() + + default, _ = configured_languages() + for translation in args.translations: + if not translation.exists(): + raise SystemExit(f"{translation}: does not exist") + source = english_source(translation, default) + if not source.exists(): + raise SystemExit(f"{translation}: no English source at {source}") + value = blob_hash(source) + translation.write_text( + restamp(translation.read_text(encoding="utf-8"), value), + encoding="utf-8", + ) + print(f"{translation}: {STAMP_KEY} = {value}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/translation_status.py b/scripts/translation_status.py new file mode 100644 index 0000000..ff22ea3 --- /dev/null +++ b/scripts/translation_status.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Report which translations are missing or out of date. + +A translation records the git blob hash of the English source it was written +against, in its own frontmatter: + + --- + translated_from: at translation time> + --- + +The English page carries nothing, so an English edit needs no ceremony: editing +it changes its content, which changes its hash, which makes every translation of +it report as stale on its own. + +Reports; never blocks. Exit status is 0 unless the check itself could not run. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +import yaml + +DOCS = Path("docs") +STAMP_KEY = "translated_from" + + +class _Loader(yaml.SafeLoader): + """mkdocs.yml carries python/name tags that SafeLoader refuses to parse.""" + + +_Loader.add_multi_constructor("", lambda loader, suffix, node: None) + + +def configured_languages() -> tuple[str, list[str]]: + """Return (default language, other languages) from the i18n plugin config.""" + config = yaml.load(Path("mkdocs.yml").read_text(encoding="utf-8"), Loader=_Loader) + for plugin in config.get("plugins", []): + if isinstance(plugin, dict) and "i18n" in plugin: + languages = plugin["i18n"]["languages"] + default = next(l["locale"] for l in languages if l.get("default")) + others = [l["locale"] for l in languages if not l.get("default")] + return default, others + raise SystemExit("mkdocs.yml has no i18n plugin configuration") + + +def blob_hash(path: Path) -> str: + return subprocess.run( + ["git", "hash-object", str(path)], + capture_output=True, text=True, check=True, + ).stdout.strip() + + +def stamp_of(path: Path) -> str | None: + """Read translated_from from a page's frontmatter, if it has one.""" + text = path.read_text(encoding="utf-8") + if not text.startswith("---\n"): + return None + end = text.find("\n---", 4) + if end == -1: + return None + front = yaml.safe_load(text[4:end]) or {} + value = front.get(STAMP_KEY) + return str(value) if value else None + + +def english_diff(stamped: str, current: Path) -> str | None: + """Diff the stamped English blob against the English page as it stands now. + + The current page is compared from the working tree rather than as a stored + object: `git hash-object` computes a hash without writing the object, so + diffing two hashes would fail on the side that was never stored. + """ + blob = subprocess.run( + ["git", "cat-file", "-p", stamped], capture_output=True, text=True, + ) + if blob.returncode != 0: + return None # stamped blob not in this clone — CI needs fetch-depth: 0 + with tempfile.TemporaryDirectory() as tmp: + was = Path(tmp) / current.name + was.write_text(blob.stdout, encoding="utf-8") + result = subprocess.run( + ["git", "diff", "--no-index", "--no-color", str(was), str(current)], + capture_output=True, text=True, + ) + # --no-index exits 1 when the files differ, which is the expected case. + # Drop the file headers: they carry a temporary path, and the page is + # already named in the surrounding report. + noise = ("diff --git ", "index ", "--- ", "+++ ") + return "\n".join( + line for line in result.stdout.splitlines() + if not line.startswith(noise) + ) + + +@dataclass +class Entry: + language: str + page: str # path relative to the language directory + state: str # missing | unstamped | stale | orphaned | current + expected: str # blob hash the translation should record + diff: str | None = None + + +def collect(default: str, languages: list[str], want_diff: bool) -> list[Entry]: + sources = sorted(p for p in (DOCS / default).rglob("*.md")) + entries: list[Entry] = [] + for source in sources: + relative = source.relative_to(DOCS / default) + expected = blob_hash(source) + for language in languages: + target = DOCS / language / relative + if not target.exists(): + entries.append(Entry(language, str(relative), "missing", expected)) + continue + stamped = stamp_of(target) + if stamped is None: + entries.append(Entry(language, str(relative), "unstamped", expected)) + elif stamped == expected: + entries.append(Entry(language, str(relative), "current", expected)) + else: + diff = english_diff(stamped, source) if want_diff else None + entries.append(Entry(language, str(relative), "stale", expected, diff)) + + # A translation whose source was deleted is invisible to the loop above, + # because that walks the sources. It is still a page being served. + for language in languages: + root = DOCS / language + for translation in sorted(root.rglob("*.md")): + if not (DOCS / default / translation.relative_to(root)).exists(): + entries.append( + Entry(language, str(translation.relative_to(root)), "orphaned", "") + ) + return entries + + +def render_text(entries: list[Entry]) -> str: + out = [] + for language in sorted({e.language for e in entries}): + rows = [e for e in entries if e.language == language] + counts = {s: sum(1 for e in rows if e.state == s) for s in + ("current", "stale", "unstamped", "missing", "orphaned")} + out.append(f"{language}: " + " ".join(f"{k}={v}" for k, v in counts.items())) + for entry in rows: + if entry.state != "current": + out.append(f" {entry.state:9s} {entry.page}") + if entry.expected: + out.append(f" {STAMP_KEY}: {entry.expected}") + return "\n".join(out) + + +def render_markdown(entries: list[Entry], only: set[str] | None) -> str: + shown = [e for e in entries if only is None or e.page in only] + out = ["## Translation status", ""] + for language in sorted({e.language for e in entries}): + rows = [e for e in entries if e.language == language] + counts = {s: sum(1 for e in rows if e.state == s) for s in + ("current", "stale", "unstamped", "missing", "orphaned")} + summary = ", ".join(f"{v} {k}" for k, v in counts.items() if v) + out.append(f"**{language}** — {summary}") + out.append("") + + behind = [e for e in shown if e.state != "current"] + if not behind: + out.append("Every translation of the pages in scope is current.") + return "\n".join(out) + + out += ["| Language | Page | State | Stamp to record |", + "|:---|:---|:---|:---|"] + for entry in behind: + out.append(f"| {entry.language} | `{entry.page}` | {entry.state} | `{entry.expected}` |") + out.append("") + + for entry in behind: + if entry.diff: + out += [f"
English changes since " + f"{entry.language}/{entry.page} was translated", + "", "```diff", entry.diff.rstrip(), "```", "", "
", ""] + elif entry.state == "stale": + out.append(f"") + return "\n".join(out) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--format", choices=("text", "markdown"), default="text") + parser.add_argument("--diff", action="store_true", + help="include the English diff for stale pages") + parser.add_argument("--only-pages", nargs="*", metavar="PATH", + help="restrict the detail section to these docs//-relative paths") + args = parser.parse_args() + + default, languages = configured_languages() + if not languages: + print("No translation languages configured.") + return 0 + + entries = collect(default, languages, want_diff=args.diff) + if args.format == "markdown": + only = set(args.only_pages) if args.only_pages else None + print(render_markdown(entries, only)) + else: + print(render_text(entries)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())