diff --git a/hooks/derniers_articles.py b/hooks/derniers_articles.py
new file mode 100644
index 0000000..af02d61
--- /dev/null
+++ b/hooks/derniers_articles.py
@@ -0,0 +1,191 @@
+"""Publie `derniers-articles.json` : ce que le wiki a écrit ou repris récemment.
+
+La page d'accueil d'alpinux.org lit ce fichier pour afficher « Le wiki, fraîchement
+mis à jour ». Il est donc écrit à chaque build, à côté des pages, et servi avec
+l'en-tête CORS sans lequel un autre domaine ne peut pas le lire.
+
+Les dates viennent de git, pas du système de fichiers : un `git clone` repose tous les
+fichiers à la même seconde, et une page recopiée depuis Obsidian aurait l'air neuve.
+Les renommages sont ignorés (`-M`, statut `R` écarté) — déplacer un article ne le rend
+pas nouveau.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from urllib.parse import urljoin
+
+from mkdocs.plugins import log
+
+FICHIER = "derniers-articles.json"
+
+# Ce que le fichier porte au plus. L'accueil n'en affiche que six, mais le fichier
+# sert aussi à qui voudrait en faire autre chose.
+NB_MAX = 20
+
+# En deçà, un article est annoncé comme « publié » plutôt que « mis à jour ».
+JOURS_NOUVEAU = 45
+
+# Les pages d'accueil de section n'annoncent rien : elles listent ce qu'il y a dessous.
+EXCLUS = {"index.md"}
+
+_pages: dict[str, dict[str, str]] = {}
+_dans_la_nav: set[str] = set()
+
+
+def on_nav(nav, config, files):
+ """Retient les pages de la navigation.
+
+ Ce qui en est absent — les redirections laissées derrière un article déplacé, par
+ exemple — n'a pas à être annoncé comme une nouveauté.
+ """
+ _dans_la_nav.clear()
+ _dans_la_nav.update(page.file.src_uri for page in nav.pages)
+
+
+def on_page_context(context, page, config, nav):
+ """Retient le titre et l'URL de chaque page rendue."""
+ _pages[page.file.src_uri] = {
+ "titre": page.title,
+ "url": urljoin(config["site_url"] or "", page.url),
+ }
+
+
+def _git(depot: Path, *args: str) -> str:
+ return subprocess.run(
+ ["git", "-C", str(depot), *args],
+ capture_output=True, text=True, check=True,
+ ).stdout
+
+
+def _est_superficiel(depot: Path) -> bool:
+ """Un clone `--depth 1` n'a pas d'historique : toutes les dates seraient identiques."""
+ try:
+ return _git(depot, "rev-parse", "--is-shallow-repository").strip() == "true"
+ except (OSError, subprocess.SubprocessError):
+ return False
+
+
+def _historique(depot: Path, dossier: str) -> tuple[dict, dict]:
+ """Rend deux tables chemin → date ISO : dernière écriture, et première.
+
+ `git log` remonte le temps : la première date vue pour un fichier est la plus
+ récente, la dernière est celle de sa création.
+ """
+ sortie = _git(
+ depot, "log", "--no-merges", "-M", "--name-status", "--format=%x01%aI",
+ "--", dossier,
+ )
+
+ modifiees: dict[str, str] = {}
+ creees: dict[str, str] = {}
+ date = ""
+
+ for ligne in sortie.splitlines():
+ if ligne.startswith("\x01"):
+ date = ligne[1:].strip()
+ continue
+ if not ligne or not date:
+ continue
+
+ champs = ligne.split("\t")
+ statut = champs[0]
+ if statut.startswith("R"): # renommage : le contenu n'a pas bougé
+ continue
+ chemin = champs.pop()
+ if not chemin.endswith(".md"):
+ continue
+
+ modifiees.setdefault(chemin, date)
+ if statut == "A":
+ creees[chemin] = date # on remonte le temps : le dernier vu est le bon
+
+ return modifiees, creees
+
+
+def _autoriser_la_lecture_croisee(site_dir: Path) -> None:
+ """Pose l'en-tête CORS sur le seul fichier qui en a besoin.
+
+ Le fichier est lu depuis alpinux.org : sans cet en-tête, le navigateur refuse la
+ réponse et la section reste masquée. Le bloc s'ajoute à un `.htaccess` déjà là
+ plutôt que de le remplacer — on ne sait pas ce qu'il porte d'autre.
+ """
+ bloc = (
+ "\n"
+ f' \n'
+ ' Header set Access-Control-Allow-Origin "*"\n'
+ " \n"
+ "\n"
+ )
+ fichier = site_dir / ".htaccess"
+ existant = fichier.read_text(encoding="utf-8") if fichier.exists() else ""
+ if FICHIER in existant:
+ return
+ separateur = "\n" if existant and not existant.endswith("\n") else ""
+ fichier.write_text(existant + separateur + bloc, encoding="utf-8")
+
+
+def on_post_build(config):
+ site_dir = Path(config["site_dir"])
+ docs_dir = Path(config["docs_dir"])
+ depot = docs_dir.parent
+
+ try:
+ modifiees, creees = _historique(depot, docs_dir.name)
+ except (OSError, subprocess.SubprocessError) as erreur:
+ log.info("derniers-articles : historique git illisible (%s), fichier non écrit",
+ erreur)
+ return
+
+ if _est_superficiel(depot):
+ log.info("derniers-articles : clone superficiel, les dates ne sont pas fiables")
+
+ seuil = datetime.now(timezone.utc) - timedelta(days=JOURS_NOUVEAU)
+ articles = []
+
+ for chemin, modifiee in modifiees.items():
+ prefixe = docs_dir.name + "/"
+ if not chemin.startswith(prefixe):
+ continue
+ src_uri = chemin[len(prefixe):]
+
+ page = _pages.get(src_uri)
+ if page is None or Path(src_uri).name in EXCLUS or src_uri not in _dans_la_nav:
+ continue
+
+ creee = creees.get(chemin, modifiee)
+ try:
+ nouveau = datetime.fromisoformat(creee) >= seuil
+ except ValueError:
+ nouveau = False
+
+ articles.append({
+ "titre": page["titre"],
+ "url": page["url"],
+ "date": creee if nouveau else modifiee,
+ "nouveau": nouveau,
+ })
+
+ if not articles:
+ log.info("derniers-articles : aucune page datée, fichier non écrit")
+ return
+
+ articles.sort(key=lambda article: article["date"], reverse=True)
+ articles = articles[:NB_MAX]
+
+ genere = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
+ contenu = json.dumps({"genere": genere, "articles": articles},
+ ensure_ascii=False, indent=2) + "\n"
+
+ try:
+ (site_dir / FICHIER).write_text(contenu, encoding="utf-8")
+ _autoriser_la_lecture_croisee(site_dir)
+ except OSError as erreur:
+ log.info("derniers-articles : écriture impossible (%s), fichier non écrit",
+ erreur)
+ return
+
+ log.info("derniers-articles : %d article(s) publié(s) dans %s", len(articles), FICHIER)
diff --git a/mkdocs.yml b/mkdocs.yml
index 44698d2..da00bf8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -66,6 +66,10 @@ extra:
copyright: "© Alpinux — LUG de Savoie | Portail"
+hooks:
+ # Écrit derniers-articles.json, que lit la page d'accueil d'alpinux.org.
+ - hooks/derniers_articles.py
+
nav:
- Accueil: index.md
- Contribuer: