"""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( "\n" f' \n' ' Header set Access-Control-Allow-Origin "*"\n' " \n" "\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)