alpinux-wiki/hooks/derniers_articles.py
Alpinux ad1f459659 Empêcher le hook des derniers articles de bloquer la publication
Le hook promettait de ne jamais faire échouer le build. En pratique il
journalisait ses échecs en « warning », et le déploiement construit avec
« --strict » : un dépôt sans historique git suffisait à obtenir « Aborted
with 1 warnings in strict mode » — plus aucune publication du wiki, pour
un fichier d'agrément.

Ses messages passent donc en « info », l'écriture des deux fichiers est
protégée à son tour, et un clone superficiel est détecté et signalé : ses
dates seraient toutes identiques.

Vérifié dans les deux cas : avec historique, vingt articles publiés ;
sans dépôt git, build réussi et fichier non écrit.

Documenté au passage que le .htaccess du DocumentRoot est désormais
réécrit à chaque déploiement, rsync --delete recopiant tout le staging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BE4rvHVETRoWnYNDTGssdo
2026-09-20 00:29:56 +02:00

189 lines
6.7 KiB
Python

"""Publie « derniers-articles.json » : les pages récemment créées ou modifiées.
Le fichier est écrit à la racine du site construit, à côté de `sitemap.xml`, et sert
la page d'accueil de https://www.alpinux.org — d'où le petit `.htaccess` qui l'ouvre
aux requêtes venues d'un autre sous-domaine.
Les dates viennent de git, pas du système de fichiers : c'est la date du commit qui a
touché le contenu de la page. **Un déplacement n'est pas une mise à jour** — renommer
`guides/truc.md` ne fait pas remonter l'article ; seule une modification de son texte
le fait.
Le hook n'échoue jamais : sans git, ou en cas d'erreur, il n'écrit simplement rien et
le build continue (le site reste valide, la page d'accueil garde ses tuiles statiques).
Ses messages sont volontairement de niveau *info* et jamais *warning* : le déploiement
construit le site avec `--strict`, où le moindre avertissement interrompt le build. Un
hook d'agrément ne doit pas pouvoir empêcher la publication du wiki.
"""
from __future__ import annotations
import json
import logging
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urljoin
log = logging.getLogger("mkdocs.hooks.derniers_articles")
FICHIER = "derniers-articles.json"
NB_MAX = 20 # articles retenus dans le JSON
JOURS_NOUVEAU = 45 # en deçà, l'article est signalé comme nouveau
EXCLUS = {"index.md"} # l'accueil du wiki n'est pas un article
# Pages construites, remplies au fil du build : chemin source -> titre et URL.
_pages: dict[str, dict[str, str]] = {}
# Pages réellement présentes dans la navigation : les seules annoncées.
_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)
return nav
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 or page.file.src_uri,
"url": urljoin(config["site_url"] or "", page.url),
}
return context
def _git(depot: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(depot), *args],
capture_output=True, text=True, check=True, timeout=30,
).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]:
"""Retourne (dernière modification, date de création) par chemin actuel.
Le journal est parcouru du plus récent au plus ancien. Les renommages sont suivis
— pour rattacher l'histoire d'un article déplacé à son chemin actuel — mais ne
comptent pas comme une modification.
"""
sortie = _git(
depot, "log", "--no-merges", "-M", "--name-status",
"--format=%x01%aI", "--", dossier,
)
alias: dict[str, str] = {} # chemin d'alors -> chemin actuel
modifiees: dict[str, str] = {} # chemin actuel -> date de la dernière modification
creees: dict[str, str] = {} # chemin actuel -> date de création
date = ""
for ligne in sortie.splitlines():
if ligne.startswith("\x01"):
date = ligne[1:].strip()
continue
if not ligne.strip() or not date:
continue
champs = ligne.split("\t")
statut = champs[0]
if statut.startswith("R") and len(champs) >= 3:
ancien, nouveau = champs[1], champs[2]
alias[ancien] = alias.pop(nouveau, nouveau)
continue
chemin = alias.get(champs[-1], champs[-1])
if not chemin.endswith(".md"):
continue
if statut.startswith("A"):
creees[chemin] = date
if statut.startswith(("A", "M")):
modifiees.setdefault(chemin, date)
return modifiees, creees
def on_post_build(config):
site_dir = Path(config["site_dir"])
docs_dir = Path(config["docs_dir"])
depot = docs_dir.parent
dossier = docs_dir.name
try:
modifiees, creees = _historique(depot, dossier)
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")
limite = datetime.now(timezone.utc) - timedelta(days=JOURS_NOUVEAU)
articles = []
for chemin, date in modifiees.items():
source = chemin[len(dossier) + 1:] if chemin.startswith(dossier + "/") else chemin
page = _pages.get(source)
if page is None or source in EXCLUS or source not in _dans_la_nav:
continue # page supprimée, hors navigation, ou volontairement écartée
creation = creees.get(chemin, date)
try:
nouveau = datetime.fromisoformat(creation) >= limite
except ValueError:
nouveau = False
articles.append({
"titre": page["titre"],
"url": page["url"],
"date": date,
"nouveau": nouveau,
})
if not articles:
log.info("derniers-articles : aucune page datée, fichier non écrit")
return
articles.sort(key=lambda a: a["date"], reverse=True)
articles = articles[:NB_MAX]
try:
(site_dir / FICHIER).write_text(
json.dumps(
{"genere": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"articles": articles},
ensure_ascii=False, indent=2,
) + "\n",
encoding="utf-8",
)
# La page d'accueil est sur un autre sous-domaine : sans cet en-tête, le
# navigateur refuse de lui laisser lire le fichier.
(site_dir / ".htaccess").write_text(
"<IfModule mod_headers.c>\n"
f' <Files "{FICHIER}">\n'
' Header set Access-Control-Allow-Origin "*"\n'
" </Files>\n"
"</IfModule>\n",
encoding="utf-8",
)
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)