From 0d72ac22de8cca35d578add89ef3348f2826712f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helmi=20Hy=C3=B6dynmaa?= Date: Tue, 4 Aug 2026 12:40:16 +0300 Subject: [PATCH 1/6] docs(i18n): stamp translations with their source hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Finnish page records the git blob hash of the English page it was translated from, in its own frontmatter. The English pages carry nothing, so editing one needs no ceremony: the edit changes its content, which changes its hash, which makes its translations report as stale on their own. All 21 were translated from the English content as it stands, so they are stamped current. An unknown frontmatter key is inert in Material — verified that it does not render, does not reach the search index, and builds clean under --strict. Part of #28. Co-Authored-By: Claude Opus 5 (1M context) --- docs/fi/appendices/compliance.md | 4 ++++ docs/fi/appendices/design-files.md | 4 ++++ docs/fi/appendices/errata.md | 4 ++++ docs/fi/appendices/resources.md | 4 ++++ docs/fi/faq.md | 4 ++++ docs/fi/getting-started/getting-started.md | 4 ++++ docs/fi/index.md | 4 ++++ docs/fi/software-development/advanced-config.md | 4 ++++ docs/fi/software-development/daemon.md | 4 ++++ docs/fi/software-development/integration.md | 4 ++++ docs/fi/software-development/ubuntu-installation.md | 4 ++++ docs/fi/technical-reference/controller.md | 4 ++++ docs/fi/technical-reference/hardware.md | 4 ++++ docs/fi/technical-reference/interfaces.md | 4 ++++ docs/fi/technical-reference/power-supply.md | 4 ++++ docs/fi/user-guide/hardware.md | 4 ++++ docs/fi/user-guide/interfaces.md | 4 ++++ docs/fi/user-guide/operation.md | 4 ++++ docs/fi/user-guide/software.md | 4 ++++ docs/fi/user-guide/troubleshooting.md | 4 ++++ docs/fi/user-guide/use-cases.md | 4 ++++ 21 files changed, 84 insertions(+) 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 From 64cce5d3570cfd1c9b513e1b79a6dfd72bb497ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helmi=20Hy=C3=B6dynmaa?= Date: Tue, 4 Aug 2026 12:40:37 +0300 Subject: [PATCH 2/6] feat(ci): report stale translations and broken anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap that made translating 21 pages risky: nothing told us when a translation fell behind its source. scripts/translation_status.py compares each translation's stamp against the current English blob and sorts pages into missing, unstamped, stale and current. For stale pages it resolves the stamped blob back through git and prints the English diff since the translation was written, which turns "this page is stale" into the specific change to apply. It also prints the stamp value to record, so nobody computes a hash by hand. scripts/check_anchors.py walks the built HTML and verifies every internal anchor resolves. Anchors derive from heading text, so translating a heading breaks links to it — including links on pages nobody touched — and mkdocs --strict does not check them at all. This broke 16 links during the Finnish translation. The workflow reports and does not block, per #25: fallback_to_default means a lagging translation serves English, so nothing is broken while it lags. It comments on PRs that touch docs/en/**, scoped to the pages that PR actually changed, and writes a full report to the job summary on every run so pre-existing drift stays visible after the PR that caused it has merged. Part of #28. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/translation-status.yml | 84 ++++++++++ scripts/check_anchors.py | 93 +++++++++++ scripts/translation_status.py | 203 +++++++++++++++++++++++ 3 files changed, 380 insertions(+) create mode 100644 .github/workflows/translation-status.yml create mode 100644 scripts/check_anchors.py create mode 100644 scripts/translation_status.py diff --git a/.github/workflows/translation-status.yml b/.github/workflows/translation-status.yml new file mode 100644 index 0000000..c1b09a1 --- /dev/null +++ b/.github/workflows/translation-status.yml @@ -0,0 +1,84 @@ +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: | + uv run python scripts/translation_status.py --format markdown --diff \ + > report.md + cat report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Check anchors + id: anchors + run: | + uv run mkdocs build --strict + { + echo "" + echo "## Anchor check" + echo "" + echo '```' + uv run python scripts/check_anchors.py site || echo "BROKEN ANCHORS — see above" + echo '```' + } >> "$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 diff --git a/scripts/check_anchors.py b/scripts/check_anchors.py new file mode 100644 index 0000000..1ea351f --- /dev/null +++ b/scripts/check_anchors.py @@ -0,0 +1,93 @@ +#!/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) + 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/translation_status.py b/scripts/translation_status.py new file mode 100644 index 0000000..e469d22 --- /dev/null +++ b/scripts/translation_status.py @@ -0,0 +1,203 @@ +#!/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 | 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)) + 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")} + 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}") + 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")} + 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()) From b24090d9e628f292d5711c2a866cb125dde80ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helmi=20Hy=C3=B6dynmaa?= Date: Tue, 4 Aug 2026 12:45:32 +0300 Subject: [PATCH 3/6] feat(ci): add a helper for writing the translation stamp Computing a blob hash and editing frontmatter by hand is exactly the mechanical step that goes wrong quietly. The helper derives the English source from the translation's own path, replaces an existing stamp rather than duplicating it, and leaves other frontmatter keys alone. Part of #28. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/stamp_translation.py | 72 ++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 scripts/stamp_translation.py diff --git a/scripts/stamp_translation.py b/scripts/stamp_translation.py new file mode 100644 index 0000000..539f257 --- /dev/null +++ b/scripts/stamp_translation.py @@ -0,0 +1,72 @@ +#!/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 + +DOCS = Path("docs") +STAMP_KEY = "translated_from" + + +def english_source(translation: Path) -> Path: + """docs// -> docs/en/.""" + parts = translation.parts + if len(parts) < 3 or parts[0] != DOCS.name: + raise SystemExit(f"{translation}: not a path under docs//") + return DOCS / "en" / 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() + + for translation in args.translations: + if not translation.exists(): + raise SystemExit(f"{translation}: does not exist") + source = english_source(translation) + 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()) From 04403b8ed4a964a2c112cede8d509baaf65852f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helmi=20Hy=C3=B6dynmaa?= Date: Tue, 4 Aug 2026 12:45:32 +0300 Subject: [PATCH 4/6] feat(skill): add the translate-page skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes translating a page a repeatable operation rather than an ad-hoc prompt. Encodes the glossary-first rule, the structure and unit requirements, the two markdown traps that neither --strict nor GitHub's preview catches, and the anchor handling — including the delayed fault where a link in an already-translated page breaks only once its target page gets translated. The stamping discipline lives here: the skill re-stamps only when it has actually re-translated. A stamp updated without translation work reports green, which is the one failure the status check cannot detect. Closes #29. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/translate-page/SKILL.md | 129 +++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .claude/skills/translate-page/SKILL.md 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. From 5486c716edaad39cf5d4d931c66cdabdf7b7bcca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helmi=20Hy=C3=B6dynmaa?= Date: Tue, 4 Aug 2026 12:57:25 +0300 Subject: [PATCH 5/6] fix(ci): fail the run on broken anchors @mairas on #30: broken anchors should fail. Unlike a stale translation, which is not broken because the page serves English, a broken anchor is a link that goes nowhere and has already shipped. The check moves last so the status report and the PR comment are still published when it fails. Also fixes a false green in the checker itself: pointing it at a missing or empty site directory reported "all anchors resolve" and exited 0, which is exactly the case where CI should be loudest. It now exits 2. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/translation-status.yml | 39 +++++++++++++++--------- scripts/check_anchors.py | 7 +++++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.github/workflows/translation-status.yml b/.github/workflows/translation-status.yml index c1b09a1..e21dd3b 100644 --- a/.github/workflows/translation-status.yml +++ b/.github/workflows/translation-status.yml @@ -33,23 +33,12 @@ jobs: - 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 \ - > report.md + | tee report.md cat report.md >> "$GITHUB_STEP_SUMMARY" - - name: Check anchors - id: anchors - run: | - uv run mkdocs build --strict - { - echo "" - echo "## Anchor check" - echo "" - echo '```' - uv run python scripts/check_anchors.py site || echo "BROKEN ANCHORS — see above" - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - name: Comment on the pull request if: github.event_name == 'pull_request' env: @@ -82,3 +71,25 @@ jobs: -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/scripts/check_anchors.py b/scripts/check_anchors.py index 1ea351f..8ad9591 100644 --- a/scripts/check_anchors.py +++ b/scripts/check_anchors.py @@ -59,6 +59,13 @@ def main() -> int: 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 From 84e3b6e58dddd09fd9f744cae3d0cfdc6105f103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helmi=20Hy=C3=B6dynmaa?= Date: Tue, 4 Aug 2026 13:00:29 +0300 Subject: [PATCH 6/6] fix(ci): report orphaned translations and refuse to stamp sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes found by probing the tools rather than reading them. A translation whose English source was deleted was invisible: the report walks the sources, so it simply stopped counting the page while the translation stayed in the tree and kept being served. Now reported as orphaned. stamp_translation.py would happily stamp a page under docs/en/, writing a self-referential hash into the source. Source pages carry no stamp by design — that is what makes an English edit ceremony-free — so it now refuses, reading the default language from mkdocs.yml rather than assuming "en". Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + scripts/stamp_translation.py | 17 +++++++++++++---- scripts/translation_status.py | 19 +++++++++++++++---- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index e7df7e7..0dd717a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ site/ .venv/ +__pycache__/ diff --git a/scripts/stamp_translation.py b/scripts/stamp_translation.py index 539f257..61fc83c 100644 --- a/scripts/stamp_translation.py +++ b/scripts/stamp_translation.py @@ -16,16 +16,24 @@ import sys from pathlib import Path +from translation_status import configured_languages + DOCS = Path("docs") STAMP_KEY = "translated_from" -def english_source(translation: Path) -> Path: - """docs// -> docs/en/.""" +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//") - return DOCS / "en" / Path(*parts[2:]) + 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: @@ -53,10 +61,11 @@ def main() -> int: 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) + source = english_source(translation, default) if not source.exists(): raise SystemExit(f"{translation}: no English source at {source}") value = blob_hash(source) diff --git a/scripts/translation_status.py b/scripts/translation_status.py index e469d22..ff22ea3 100644 --- a/scripts/translation_status.py +++ b/scripts/translation_status.py @@ -102,7 +102,7 @@ def english_diff(stamped: str, current: Path) -> str | None: class Entry: language: str page: str # path relative to the language directory - state: str # missing | unstamped | stale | current + state: str # missing | unstamped | stale | orphaned | current expected: str # blob hash the translation should record diff: str | None = None @@ -126,6 +126,16 @@ def collect(default: str, languages: list[str], want_diff: bool) -> list[Entry]: 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 @@ -134,12 +144,13 @@ def render_text(entries: list[Entry]) -> str: 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")} + ("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}") - out.append(f" {STAMP_KEY}: {entry.expected}") + if entry.expected: + out.append(f" {STAMP_KEY}: {entry.expected}") return "\n".join(out) @@ -149,7 +160,7 @@ def render_markdown(entries: list[Entry], only: set[str] | None) -> str: 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")} + ("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("")