Republier derniers-articles.json, que l'accueil attend

La page d'accueil d'alpinux.org masque sa section « Le wiki, fraîchement
mis à jour » quand le fichier qu'elle lit répond 404 — ce qu'il faisait
depuis que le hook qui l'écrivait a disparu du dépôt. Le voici remis :
titres, URL et dates des vingt dernières pages, écrit à chaque build.

Les dates viennent de git et non du disque, sans quoi un clone ferait
paraître tout le wiki écrit à la seconde près. Les renommages sont
écartés : déplacer un article ne le republie pas. Le .htaccess pose
l'en-tête qui autorise alpinux.org à lire le fichier, et s'ajoute à
celui qui serait déjà là plutôt que de le remplacer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTvii1CYBLjD14uvuD7Kos
This commit is contained in:
Alpinux 2026-09-20 10:14:57 +02:00
parent 4c973a5988
commit e10cafe06a
2 changed files with 195 additions and 0 deletions

191
hooks/derniers_articles.py Normal file
View file

@ -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 = (
"<IfModule mod_headers.c>\n"
f' <Files "{FICHIER}">\n'
' Header set Access-Control-Allow-Origin "*"\n'
" </Files>\n"
"</IfModule>\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)

View file

@ -66,6 +66,10 @@ extra:
copyright: "© Alpinux — LUG de Savoie | <a href='https://portail.alpinux.org'>Portail</a>" copyright: "© Alpinux — LUG de Savoie | <a href='https://portail.alpinux.org'>Portail</a>"
hooks:
# Écrit derniers-articles.json, que lit la page d'accueil d'alpinux.org.
- hooks/derniers_articles.py
nav: nav:
- Accueil: index.md - Accueil: index.md
- Contribuer: - Contribuer: