diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f036fc..bba6595 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -124,3 +124,21 @@ jobs:
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
with:
command: check advisories bans licenses sources
+
+ i18n:
+ name: translations up to date
+ runs-on: ubuntu-latest
+ steps:
+ # No `fetch-depth` needed, and that is the point: the check compares the
+ # BLOB HASH of each English source against the stamp its translations
+ # carry, which is computable from the working tree alone. The obvious
+ # alternative, `git log` on the source, reports a single commit on the
+ # depth-1 clone Actions makes by default, so every comparison would come
+ # out equal and this job would pass green forever while translations rot.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Check every translation against its English source
+ run: ./scripts/i18n-check.sh
+
+ - name: Check every link and anchor resolves
+ run: ./scripts/i18n-links.py
diff --git a/README.md b/README.md
index 2e7711a..8b82fe4 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,13 @@ Full documentation is in [docs/](docs/). Start at
[docs/README.md](docs/README.md). To report a vulnerability, see
[SECURITY.md](SECURITY.md).
+## Language
+
+Some pages are translated. **English is canonical**: when a translation
+disagrees with it, the English file is right.
+
+- **Français** - [README](docs/i18n/fr/README.md) · [documentation index](docs/i18n/fr/docs/README.md) · [goals and decisions](docs/i18n/fr/docs/project/decisions.md)
+
## Code signing policy
Every release asset is signed by the Project Colony organisation in CI, never on
diff --git a/docs/README.md b/docs/README.md
index c3e17b0..01e6906 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -20,6 +20,16 @@ Sorted by who is reading, not by subject.
| | |
|---|---|
-| [project/decisions.md](project/decisions.md) | what Xion is for, and the technical choices that follow from it |
+| [project/decisions.md](project/decisions.md) | what Xion is for, the technical choices that follow from it, and the documentation still to write |
+
+## How these pages stay true
+
+No page carries a "reviewed" checkbox. A ticked box proves no review took
+place: an earlier index certified three pages as reviewed and aligned with the
+code while two of them described structures and paths that no longer existed.
+
+Instead, each page cites its sources: a file and a symbol, or a test name. A
+line number is a reading taken on one day; when it disagrees with the code, the
+symbol it names is what counts.
Security policy: [../SECURITY.md](../SECURITY.md).
diff --git a/docs/i18n/fr/README.md b/docs/i18n/fr/README.md
new file mode 100644
index 0000000..5e13499
--- /dev/null
+++ b/docs/i18n/fr/README.md
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
+**Un explorateur de fichiers à onglets, avec un vrai panneau terminal, identique sous Linux et Windows.**
+
+
+
+[](../../../LICENSE)
+[](https://github.com/Project-Colony/Colony)
+[](#installation)
+
+Chaque bureau livre son propre gestionnaire de fichiers : Nautilus sous GNOME,
+Dolphin sous KDE, l'Explorateur sous Windows. Chacun appartient à sa
+plateforme, si bien que changer de machine veut dire changer d'outil, de
+raccourcis et d'habitudes. Xion est un seul explorateur pour Linux et Windows,
+avec des onglets, des aperçus, des archives et un panneau terminal, écrit en
+Rust sur la boîte à outils que partagent tous les programmes de
+[Project Colony](https://github.com/Project-Colony).
+
+> **État :** jeune. Prouvé : chaque modification est compilée et testée sous
+> Linux et Windows en CI, ainsi que sur la version minimale de Rust ;
+> `cargo deny` ne signale aucun avis ouvert ; les versions sont publiées
+> signées, et les signatures se vérifient. Branché mais non testé :
+> l'intégration au menu contextuel de Windows (`xion --register`) n'a été
+> essayée qu'à la main, et le passage de relais à l'instance unique n'existe
+> que sous Linux (via D-Bus) : sous Windows, chaque lancement ouvre une
+> nouvelle fenêtre.
+
+## Pourquoi Xion
+
+- **Un seul programme sur les deux systèmes.** La même interface, les mêmes
+ raccourcis et le même fichier de réglages sous Linux et sous Windows, au
+ lieu d'un gestionnaire de fichiers différent sur chacun.
+- **Un vrai terminal à côté des fichiers.** Le panneau du bas s'ouvre dans le
+ dossier affiché, a ses propres onglets, et c'est un émulateur de terminal
+ complet : `vim`, `htop` et Ctrl+C s'y comportent comme partout ailleurs.
+- **Les archives sans autre outil.** Les archives ZIP, `.tar.gz` et 7z se
+ parcourent comme des dossiers, s'extraient et se créent sur place, et
+ l'extraction refuse toute entrée qui atterrirait hors de la destination.
+- **Membre d'un écosystème.** Thèmes, accents, taille du texte, police adaptée
+ à la dyslexie et animations réduites viennent du même catalogue et de la
+ même disposition des Préférences que dans tous les autres programmes Colony.
+
+## Ce qu'il fait
+
+- Onglets, historique précédent et suivant, et une barre d'adresse modifiable
+ avec suggestions.
+- Une barre latérale avec les lecteurs, les favoris, l'accès rapide, les
+ emplacements récents et une arborescence des dossiers, et un second volet
+ facultatif.
+- Vues en liste et en grille avec des colonnes redimensionnables et triables,
+ un filtre rapide, et des étiquettes de couleur sur les fichiers.
+- Copier, déplacer, renommer (un fichier ou plusieurs à la fois) et supprimer,
+ avec progression et annulation de la dernière action. La suppression passe
+ par la corbeille ; Maj+Suppr supprime définitivement après confirmation.
+- Archives : parcourir, extraire et créer des `.zip`, `.tar.gz` et `.7z`.
+- Recherche par nom dans un index en mémoire du dossier, et une recherche
+ plein texte récursive qui peut respecter `.gitignore`.
+- Aperçus : métadonnées, images, texte avec coloration syntaxique, vue
+ hexadécimale, et différence entre deux fichiers. Les propriétés affichent le
+ SHA-256 d'un fichier et permettent de modifier ses permissions.
+- Dans un dépôt git, les fichiers modifiés, nouveaux, indexés et en conflit
+ sont signalés dans la liste.
+- Un panneau terminal intégré, construit sur `iced_term`
+ (`alacritty_terminal`), qui lance votre propre shell.
+- Les emplacements montés par gvfs (SMB, SFTP, téléphones, Google Drive) dans
+ la barre latérale, et les services SMB, FTP, HTTP et d'impression qu'annonce
+ votre réseau local.
+- Une interface en anglais et en français. Préférences > Général > Langue en
+ choisit une, ou suit par défaut la langue de la session.
+- Des préférences d'apparence et d'accessibilité partagées avec l'écosystème
+ Colony : tous les thèmes et accents du catalogue Colony, le contraste élevé,
+ deux tailles de texte qui se multiplient, la police OpenDyslexic, et les
+ animations réduites, qui coupent toute animation. Le bouton **Xion** en haut
+ à gauche ouvre les Préférences.
+- Les réglages dans un seul fichier TOML versionné sous `Colony/Xion/`,
+ enregistrés à chaque modification et rechargés avec Ctrl+R.
+
+## Installation
+
+### Via Colony (recommandé)
+
+Cherchez **Xion** dans [Colony](https://github.com/Project-Colony/Colony) et
+installez-le. Les mises à jour arrivent par le lanceur, qui vérifie la
+signature de chaque version avant de l'installer.
+
+### Téléchargement direct du binaire
+
+Récupérez le fichier de votre plateforme dans la
+[dernière version](https://github.com/Project-Colony/Xion/releases/latest) :
+
+| Plateforme | Fichier |
+|---|---|
+| Linux | `xion-linux` |
+| Windows | `xion-windows.exe` |
+
+```bash
+chmod +x xion-linux && ./xion-linux
+```
+
+Pour vérifier d'abord le téléchargement, voir la
+[politique de signature du code](#politique-de-signature-du-code).
+
+### Compiler depuis les sources
+
+```bash
+git clone https://github.com/Project-Colony/Xion
+cd Xion
+cargo build --release
+./target/release/xion
+```
+
+Demande Rust 1.95 ou plus récent ; `rust-toolchain.toml` épingle la chaîne
+d'outils qu'utilise la CI, et rustup l'installe à la première compilation.
+Au-delà de Rust, la compilation ne demande qu'un compilateur C
+(`build-essential` sous Debian ou Ubuntu) pour le code C embarqué de libgit2,
+zlib et zstd. Les bibliothèques de fenêtrage sont chargées à l'exécution :
+aucun paquet de développement n'est nécessaire.
+
+Sous Linux, `scripts/install.sh` compile un binaire optimisé et l'installe
+avec ses icônes et son entrée de bureau sous `~/.local`, sans `sudo`.
+`scripts/install.sh --uninstall` retire exactement ce qu'il a installé.
+
+## Documentation
+
+La documentation complète est dans [docs/](../../../docs/README.md), en
+anglais ; son index est traduit : [docs/README.md](docs/README.md). Pour
+signaler une vulnérabilité, voir [SECURITY.md](../../../SECURITY.md) (en
+anglais).
+
+## Langue
+
+Certaines pages sont traduites. **L'anglais fait foi** : quand une traduction
+le contredit, c'est le fichier anglais qui a raison.
+
+- **English** - [README](../../../README.md) · [documentation](../../../docs/README.md) · [goals and decisions](../../../docs/project/decisions.md)
+
+## Politique de signature du code
+
+Chaque fichier publié est signé par l'organisation Project Colony en CI,
+jamais sur une machine de développement. À côté de chaque fichier, sur la page
+de la version :
+
+| Fichier | Ce que c'est |
+|---|---|
+| `.sig` | une signature ed25519 du fichier, faite avec la clé de publication de l'organisation |
+| `.meta` | trois lignes qui lient le fichier à son nom, à son sha256 et à la version publiée |
+| `.meta.sig` | une signature ed25519 du `.meta` |
+
+La clé privée est un secret de l'organisation, utilisé seulement par le
+[workflow partagé de signature et de publication](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/.github/workflows/sign-and-publish.yml),
+dans un job qui ne compile rien ; les jobs qui compilent Xion ne la voient
+jamais. Colony vérifie les trois fichiers avant d'installer ou de mettre à
+jour Xion, et refuse une version plus ancienne que celle installée. Pour
+vérifier vous-même un téléchargement avec OpenSSL 3 (les mêmes commandes
+valent pour chaque fichier) :
+
+```bash
+cat > colony-release.pub <<'EOF'
+-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEARNjg3Nn8H6/aBg1unwGjkUTcrdTxERNefVaqU8cFu0s=
+-----END PUBLIC KEY-----
+EOF
+a=xion-linux
+openssl pkeyutl -verify -pubin -inkey colony-release.pub -rawin -in "$a" -sigfile "$a.sig"
+openssl pkeyutl -verify -pubin -inkey colony-release.pub -rawin -in "$a.meta" -sigfile "$a.meta.sig"
+cat "$a.meta" # version=, asset=, sha256=
+sha256sum "$a" # le condensat doit être celui de la ligne sha256
+```
+
+Comment les versions sont compilées, signées et publiées :
+[design/releases.md](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/design/releases.md#5-signing)
+(en anglais).
+
+## Vie privée
+
+Xion n'envoie ni télémétrie, ni statistiques, ni rapports de plantage. Il n'a
+pas de compte et ne cherche pas de mises à jour : c'est Colony qui s'en
+charge.
+
+| Donnée | Stockée ou envoyée | Où, et pourquoi |
+|---|---|---|
+| Réglages | stockés | `Colony/Xion/config.toml` dans le dossier de configuration de cette machine : vos préférences, favoris, étiquettes de couleur et largeurs de colonnes |
+| Onglets ouverts | stockés | `Colony/Xion/session.toml` dans le dossier de données de cette machine, pour les rouvrir au prochain démarrage |
+| Exploration du réseau | envoyée, sur votre réseau local uniquement | quand vous ouvrez ou actualisez *Réseau* dans la barre latérale, au plus toutes les 30 secondes : les requêtes mDNS standard pour les services SMB, FTP, HTTP et d'impression, pendant quelques secondes. Sous Windows, Xion demande aussi au système les partages SMB qu'il voit. Ce qui répond est listé, marqué comme non vérifié |
+| Menu contextuel de Windows | stocké | des entrées sous `HKEY_CURRENT_USER\Software\Classes`, seulement après `xion --register` ; `xion --unregister` les retire |
+
+Xion ne se connecte à aucun serveur, et ne touche votre réseau local que pour
+l'exploration ci-dessus. Il ne demande ni ne stocke aucun mot de passe. Les
+miniatures et l'index de recherche restent en mémoire. Il lance pour vous des
+programmes locaux : votre shell dans le panneau terminal, `ffmpeg`, `mutool`
+ou `pdftoppm` pour les miniatures de vidéos et de PDF quand ils sont
+installés, et l'ouvreur du système pour les fichiers que vous ouvrez. Ouvrir
+un partage réseau ou un emplacement gvfs est un accès ordinaire aux fichiers,
+par le système.
+
+## Licence
+
+GPL-3.0-or-later. Voir [LICENSE](../../../LICENSE). Les polices de Xion
+(JetBrains Mono Nerd Font, OpenDyslexic et Font Awesome 6 Free) sont
+embarquées dans [colony-ui](https://crates.io/crates/colony-ui), qui les livre
+avec le texte de leurs licences.
diff --git a/docs/i18n/fr/docs/README.md b/docs/i18n/fr/docs/README.md
new file mode 100644
index 0000000..fe2dea8
--- /dev/null
+++ b/docs/i18n/fr/docs/README.md
@@ -0,0 +1,39 @@
+
+
+# Documentation de Xion
+
+Triée par lecteur, pas par sujet. Seules cette page et « Objectifs et
+décisions » sont traduites ; les autres pages sont en anglais.
+
+## Utiliser Xion
+
+| | |
+|---|---|
+| [guide/configuration.md](../../../../docs/guide/configuration.md) | où vivent les réglages, chaque clé de `config.toml`, et les raccourcis clavier |
+
+## Lire le code
+
+| | |
+|---|---|
+| [internals/architecture.md](../../../../docs/internals/architecture.md) | comment le crate est découpé, compilé et testé, et où le code s'écarte de ses propres règles |
+| [internals/ui-routing.md](../../../../docs/internals/ui-routing.md) | ce que sont une route, une sélection et un onglet, et comment la navigation passe de l'un à l'autre |
+| [internals/search.md](../../../../docs/internals/search.md) | comment l'index de recherche est construit, sur quoi il filtre, et comment l'interface le pilote |
+
+## Pourquoi il existe
+
+| | |
+|---|---|
+| [project/decisions.md](project/decisions.md) | à quoi sert Xion, les choix techniques qui en découlent, et la documentation qui reste à écrire |
+
+## Sur la relecture des documents
+
+Aucune page ne porte de case « relu » à cocher. Une case cochée ne prouve pas
+qu'une relecture a eu lieu : un ancien index certifiait que trois documents
+étaient revus et alignés sur l'implémentation, alors que deux d'entre eux
+décrivaient des structures et des chemins qui n'existaient plus.
+
+À la place, chaque page cite ses sources : un fichier et un symbole, ou un nom
+de test. Un numéro de ligne est un relevé daté ; en cas de divergence avec le
+code, c'est le symbole cité qui fait foi.
+
+Politique de sécurité : [SECURITY.md](../../../../SECURITY.md).
diff --git a/docs/i18n/fr/docs/project/decisions.md b/docs/i18n/fr/docs/project/decisions.md
new file mode 100644
index 0000000..4e2c7eb
--- /dev/null
+++ b/docs/i18n/fr/docs/project/decisions.md
@@ -0,0 +1,69 @@
+
+
+# Objectifs et décisions
+
+## But du projet
+
+Un explorateur de fichiers pour Linux et Windows, inspiré de Windows File
+Explorer, Files et Dolphin : onglets, barre latérale, vues en liste et en
+grille, aperçus, archives, un terminal à côté des fichiers. Il fait partie de
+Project Colony : il partage donc l'apparence, la disposition des réglages et le
+processus de publication de l'écosystème avec les autres programmes, au lieu
+d'inventer les siens.
+
+## Règles persistantes
+
+- **Léger et réactif.** Les dossiers se chargent paresseusement, une page à la
+ fois ; les listes et l'arborescence ne rendent que les lignes visibles ; les
+ métadonnées sont lues par lots ; chaque cache (miniatures, dossiers, index de
+ recherche) est borné en taille et en durée de vie ; le travail bloquant ne
+ tourne jamais dans la boucle de rendu.
+- **Séparation par responsabilité.** La couche d'accès au système de fichiers
+ ignore tout de l'interface, ce qui rend possibles la virtualisation et la
+ plupart des tests sans fenêtre. Là où le code enfreint cette règle, l'écart
+ est consigné dans
+ [architecture.md](../../../../../docs/internals/architecture.md#where-the-code-falls-short)
+ (en anglais).
+- **La configuration plutôt que le hardcode.** Un comportement qu'un
+ utilisateur pourrait raisonnablement vouloir autre est un réglage, versionné,
+ migré et validé
+ ([configuration.md](../../../../../docs/guide/configuration.md), en anglais).
+- **Rust à jour, dépendances à jour.** Rust édition 2024, avec `Cargo.toml` et
+ `Cargo.lock` maintenus à jour.
+- **Clavier et souris à égalité.** L'interface entière doit pouvoir s'utiliser
+ avec l'un comme avec l'autre.
+- **Mesuré, pas supposé.** Les affirmations de performance dans le code
+ s'accompagnent de la mesure qui les étaye ; les mesures sont dans
+ `tests/benches.rs`.
+
+## Décisions techniques
+
+| Décision | Pourquoi |
+|---|---|
+| **iced 0.14 sur wgpu** pour l'interface | une interface native, dessinée par le GPU, en Rust : la boîte à outils de tous les programmes Colony |
+| **colony-ui** pour les couleurs, les polices, les tailles de texte, les animations, les Préférences et les chemins | Xion ressemble au reste de l'écosystème et se comporte comme lui, et un thème ajouté au catalogue partagé y apparaît sans recompiler Xion |
+| **Le thème est une sélection dans un catalogue** (famille, variante, contraste élevé, accent), pas un ensemble fermé de noms | un thème inconnu ou retiré dégrade vers la palette par défaut au lieu d'empêcher Xion de démarrer |
+| **Fichiers sous `Colony/Xion/`** | la disposition de l'écosystème : les réglages dans le dossier de configuration, la session dans le dossier de données |
+| **Interface en anglais avec une traduction française**, qui suit la langue de la session sauf choix contraire | l'anglais est la langue de base de l'écosystème ; le français est la seconde locale que livre chaque programme Colony |
+| **`iced_term` (alacritty_terminal) pour le panneau terminal** | un vrai terminal : programmes plein écran, couleurs, Ctrl+C et redimensionnement fonctionnent, là où un ancien modèle maison, un historique de lignes, ne pouvait lancer ni `vim` ni `htop` |
+| **`notify` pour l'observateur de fichiers** | le mécanisme natif de chaque plateforme, avec un repli inerte quand il ne peut pas démarrer |
+| **Cibles : `x86_64-unknown-linux-gnu` et `x86_64-pc-windows-msvc`** | les deux plateformes que la CI compile et teste, et les deux fichiers publiés à chaque version |
+| **`asInvoker` sous Windows**, entrées du menu contextuel sous `HKEY_CURRENT_USER` uniquement | un explorateur de fichiers n'a aucune raison de s'élever, et tout ce qu'il lance hériterait de l'élévation |
+| **La suppression passe par la corbeille** par défaut | une suppression définitive est une action à part, derrière une confirmation |
+| **Versions signées en CI uniquement**, via le workflow partagé de Project Colony | la clé de signature n'atteint jamais une machine de développement ni un job de compilation ; voir la [politique de signature du code](../../README.md#politique-de-signature-du-code) du README |
+
+## Roadmap documentaire
+
+Points ouverts, repris de la roadmap documentaire de l'ancien `docs/docs.md` en
+français. L'architecture des modules, les stratégies de cache, l'indexation et
+la recherche, et le modèle de navigation et de sélection sont écrits ; ceux-ci
+ne le sont pas :
+
+- [ ] Diagrammes d'interaction : les modules et les flux de données entre eux.
+ [architecture.md](../../../../../docs/internals/architecture.md#data-flow)
+ en donne une courte esquisse, pas un diagramme.
+- [ ] Cycle de rendu Iced et modèle d'état détaillé.
+- [ ] Rattacher les exemples de code de `docs/` au crate, pour que
+ `cargo test --doc` les compile et qu'ils ne puissent plus pourrir.
+- [ ] Porter en tête de chaque page la date à laquelle elle a été confrontée
+ au code (voir [Sur la relecture des documents](../README.md#sur-la-relecture-des-documents)).
diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md
index 98205ef..255532f 100644
--- a/docs/internals/architecture.md
+++ b/docs/internals/architecture.md
@@ -314,8 +314,9 @@ CI (`.github/workflows/ci.yml`) runs on every pull request and every push to
| `ubuntu-latest`, `windows-latest` | format check, clippy with warnings as errors, every test, and a check that no source file starts with a UTF-8 byte order mark |
| `msrv` | `cargo check` and `cargo test` on Rust 1.95 |
| `advisories` | `cargo deny check advisories bans licenses sources` |
+| `translations up to date` | `scripts/i18n-check.sh`, which fails when a page under `docs/i18n/` no longer matches the English it was translated from, and `scripts/i18n-links.py`, which fails on a Markdown link or anchor that leads nowhere |
-All four are required before anything merges into `main`. Releases are built,
+The first four are required before anything merges into `main`. Releases are built,
signed and published by `.github/workflows/release.yml` through the shared
Project Colony workflow; the README's
[Code signing policy](../../README.md#code-signing-policy) says how to check
diff --git a/docs/project/decisions.md b/docs/project/decisions.md
index d2ec84b..8973ce1 100644
--- a/docs/project/decisions.md
+++ b/docs/project/decisions.md
@@ -21,6 +21,10 @@ instead of inventing its own.
- **Configuration over hard-coding.** Behaviour that a user could reasonably
want different is a setting, versioned, migrated and validated
([../guide/configuration.md](../guide/configuration.md)).
+- **Current Rust, current dependencies.** Rust edition 2024, with
+ `Cargo.toml` and `Cargo.lock` kept up to date.
+- **Keyboard and mouse alike.** The whole interface is meant to be usable with
+ either one.
- **Measured, not assumed.** Performance claims in the code come with the
measurement that backs them; the measurements live in `tests/benches.rs`.
@@ -39,3 +43,18 @@ instead of inventing its own.
| **`asInvoker` on Windows**, context-menu entries under `HKEY_CURRENT_USER` only | a file explorer has no reason to run elevated, and everything it starts would inherit the elevation |
| **Deleting goes to the trash** by default | a permanent delete is a separate action, behind a confirmation |
| **Releases signed in CI only**, through the shared Project Colony workflow | the signing key never reaches a developer machine or a build job; see the README's [Code signing policy](../../README.md#code-signing-policy) |
+
+## Documentation still to write
+
+Open items, carried over from the documentation roadmap of the earlier French
+`docs/docs.md`. The module map, the caches, search and the navigation model are
+written; these are not:
+
+- [ ] Interaction diagrams: the modules and the data flowing between them.
+ [../internals/architecture.md](../internals/architecture.md#data-flow) has a
+ short sketch, not a diagram.
+- [ ] The iced render cycle and the state model, in detail.
+- [ ] Tie the code examples in `docs/` to the crate, so that
+ `cargo test --doc` compiles them and they can no longer rot.
+- [ ] Give each page the date it was last checked against the code (see
+ [How these pages stay true](../README.md#how-these-pages-stay-true)).
diff --git a/scripts/i18n-check.sh b/scripts/i18n-check.sh
new file mode 100755
index 0000000..ee8a904
--- /dev/null
+++ b/scripts/i18n-check.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+# Whether every translated document still matches the English it was made from.
+#
+# Each translation carries the BLOB HASH of its source, not a commit sha:
+#
+#
+#
+# Translations live at docs/i18n//, so a translation is
+# a mirror of the repo root: docs/i18n/fr/docs/project/decisions.md translates
+# docs/project/decisions.md. That shape is what makes a link between two translated
+# pages the SAME string as the link between their English originals.
+#
+# The hash is `git hash-object `, computable from the working tree with
+# no history at all. That is deliberate. The obvious design - record the last
+# commit that touched the source and compare with `git log` - needs history this
+# repo's CI does not fetch: `actions/checkout` clones with depth 1 unless told
+# otherwise, `git log` on a shallow clone reports one commit, every comparison
+# comes out equal, and the check passes green forever while translations rot.
+# A freshness check that cannot fail is worse than none: it is a badge asserting
+# something nobody verified.
+#
+# Exit 1 if any translation is stale or malformed. `--fix` restamps instead,
+# for when you have just re-translated.
+set -uo pipefail
+cd "$(dirname "$0")/.."
+
+fix=0
+stamp=0
+case "${1:-}" in
+ --fix) fix=1 ;;
+ # Adds a missing header to a translated file. An authoring convenience, kept
+ # separate from --fix on purpose: CI must keep FAILING on an unstamped file,
+ # because a translation with no stamp is one nothing can ever tell is stale.
+ --stamp) stamp=1 ;;
+esac
+
+stale=0
+missing=0
+checked=0
+
+# A translation's source is its path with the docs/i18n// prefix removed.
+while IFS= read -r t; do
+ checked=$((checked + 1))
+ # Strip `find`'s leading `./`: the stamp inside a file names its source the
+ # way a human would write it, repo-relative, and a path that does not match
+ # the stamp byte for byte makes --fix silently substitute nothing.
+ t="${t#./}"
+ src="$(printf '%s' "$t" | sed -E 's|^docs/i18n/[^/]+/||')"
+ if [ ! -f "$src" ]; then
+ printf 'ORPHAN %s\n its English source %s does not exist\n' "$t" "$src"
+ missing=$((missing + 1))
+ continue
+ fi
+ want="$(git hash-object "$src")"
+ have="$(sed -n 's/.*xion-i18n: source=[^ ]* sha=\([0-9a-f]*\).*/\1/p' "$t" | head -1)"
+ if [ -z "$have" ]; then
+ if [ "$stamp" = 1 ]; then
+ printf '\n\n%s\n' "$src" "$want" "$(cat "$t")" > "$t.tmp"
+ mv "$t.tmp" "$t"
+ printf 'STAMPED %s\n' "$t"
+ continue
+ fi
+ printf 'NO STAMP %s\n add: \n' "$t" "$src" "$want"
+ missing=$((missing + 1))
+ continue
+ fi
+ if [ "$have" != "$want" ]; then
+ if [ "$fix" = 1 ]; then
+ sed -i "s|xion-i18n: source=$src sha=$have|xion-i18n: source=$src sha=$want|" "$t"
+ # Confirm the substitution LANDED before saying so. The first version
+ # of this script printed RESTAMPED unconditionally, and because a
+ # path mismatch made the pattern match nothing, it announced success
+ # while changing not one byte - the exact failure this whole check
+ # exists to prevent, inside the check itself.
+ if [ "$(sed -n 's/.*xion-i18n: source=[^ ]* sha=\([0-9a-f]*\).*/\1/p' "$t" | head -1)" = "$want" ]; then
+ printf 'RESTAMPED %s\n' "$t"
+ else
+ printf 'COULD NOT RESTAMP %s\n its stamp does not name %s\n' "$t" "$src"
+ stale=$((stale + 1))
+ fi
+ else
+ n="$(git log --oneline "$src" 2>/dev/null | wc -l)"
+ printf 'STALE %s\n %s changed since this was translated (stamped %s, now %s)\n' \
+ "$t" "$src" "${have:0:8}" "${want:0:8}"
+ [ "$n" -gt 1 ] && printf ' history: %s commits on the source\n' "$n"
+ stale=$((stale + 1))
+ fi
+ fi
+done < <(find docs/i18n -name '*.md' 2>/dev/null | sort)
+
+# Structure. Nobody here reads fifteen languages, so the honest check on a
+# translation's BODY is not its prose but its shape: a page with the source's
+# headings, code blocks and table rows is a page somebody actually translated,
+# and one with half of them is a page that was summarised, truncated, or had its
+# examples "helpfully" merged. This catches the failure a reviewer cannot.
+shape() {
+ # headings | fenced code blocks | table rows, for one file
+ printf '%s %s %s' \
+ "$(grep -c '^#\{1,6\} ' "$1")" \
+ "$(grep -c '^```' "$1")" \
+ "$(grep -c '^|' "$1")"
+}
+
+for t in $(find docs/i18n -name '*.md' 2>/dev/null | sort); do
+ t="${t#./}"
+ src="$(printf '%s' "$t" | sed -E 's|^docs/i18n/[^/]+/||')"
+ [ -f "$src" ] || continue
+ # The stamp line is one extra line in the translation and nothing else.
+ a="$(shape "$src")"
+ b="$(shape "$t")"
+ if [ "$a" != "$b" ]; then
+ printf 'SHAPE %s\n headings/code-fences/table-rows: source %s, translation %s\n' \
+ "$t" "$a" "$b"
+ stale=$((stale + 1))
+ fi
+done
+
+printf '\n%s translation(s) checked' "$checked"
+[ "$stale" -gt 0 ] && printf ', %s STALE' "$stale"
+[ "$missing" -gt 0 ] && printf ', %s unusable' "$missing"
+printf '\n'
+
+if [ "$stale" -gt 0 ] || [ "$missing" -gt 0 ]; then
+ cat <<'MSG'
+
+A stale translation is worse than a missing one: it looks authoritative and
+gives last month's instructions. Either re-translate it and run this with
+--fix, or delete it and let the reader fall back to English.
+MSG
+ exit 1
+fi
+exit 0
diff --git a/scripts/i18n-links.py b/scripts/i18n-links.py
new file mode 100755
index 0000000..01153ed
--- /dev/null
+++ b/scripts/i18n-links.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""Every markdown link and anchor in the docs resolves.
+
+Split from i18n-check.sh because it needs a real slug function: an anchor is
+GitHub's slug of a heading, and a translated page's headings are - obviously -
+not the English ones. The failure this exists to catch is quiet and specific:
+a link whose page exists but whose anchor does not, because a translation kept
+an English anchor while pointing at a page whose headings it translated. It
+lands on the right page at the wrong place, or nowhere at all.
+"""
+import glob
+import os
+import re
+import sys
+
+ANCHOR = re.compile(r"\]\(([^)\s#]*\.md)(?:#([^)\s]+))?\)")
+
+
+def slug(heading: str) -> str:
+ s = heading.strip().lower()
+ s = re.sub(r"`([^`]*)`", r"\1", s)
+ s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s)
+ s = re.sub(r"[*_]", "", s)
+ s = "".join(c for c in s if c.isalnum() or c in " -_")
+ return s.strip().replace(" ", "-")
+
+
+def headings(path: str) -> set:
+ with open(path, encoding="utf-8") as fh:
+ return {slug(l.lstrip("#").strip()) for l in fh if l.startswith("#")}
+
+
+def main() -> int:
+ bad = 0
+ files = sorted(f for f in glob.glob("**/*.md", recursive=True) if not f.startswith("target/"))
+ for f in files:
+ d = os.path.dirname(f)
+ with open(f, encoding="utf-8") as fh:
+ text = fh.read()
+ for m in ANCHOR.finditer(text):
+ target, anchor = m.group(1), m.group(2)
+ if target.startswith("http"):
+ continue
+ p = os.path.normpath(os.path.join(d, target))
+ if not os.path.exists(p):
+ print(f"MISSING FILE {f}\n -> {target}")
+ bad += 1
+ continue
+ if anchor and anchor not in headings(p):
+ print(f"MISSING ANCHOR {f}\n -> {target}#{anchor}")
+ bad += 1
+ print(f"\n{len(files)} markdown file(s) checked", end="")
+ print(f", {bad} broken link(s)" if bad else ", every link resolves")
+ return 1 if bad else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())