"""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). """ 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 _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.warning("derniers-articles : historique git illisible (%s), fichier non écrit", erreur) return 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.warning("derniers-articles : aucune page datée, fichier non écrit") return articles.sort(key=lambda a: a["date"], reverse=True) articles = articles[:NB_MAX] (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", ) log.info("derniers-articles : %d article(s) publié(s) dans %s", len(articles), FICHIER)