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: