"""Publie ce que le wiki a écrit ou repris, pour qui l'affiche ailleurs. Deux fichiers, écrits à chaque build à côté des pages et servis avec l'en-tête CORS sans lequel un autre domaine ne peut pas les lire : - `derniers-articles.json` — les vingt dernières pages touchées, que la page d'accueil d'alpinux.org lit pour sa section « Le wiki, fraîchement mis à jour » ; - `toutes-les-pages.json` — le même inventaire, sans limite de nombre et avec la rubrique de chaque page, pour la page des mises à jour d'alpinux.org. Les deux sortent du même calcul : le second est la liste entière, le premier son début. 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_RECENTS = "derniers-articles.json" FICHIER_TOUTES = "toutes-les-pages.json" # Ce que le premier fichier porte au plus. L'accueil n'en affiche que six, mais le # fichier sert aussi à qui voudrait en faire autre chose. Le second ne plafonne pas. 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() _rubriques: dict[str, str] = {} def _rubrique_par_page(items, rubrique=""): """Associe chaque page de la navigation au titre de la section qui la porte. On garde la section de premier niveau — « Guides », « Contribuer » — plutôt que la plus proche : c'est l'échelle à laquelle un lecteur range les pages. """ for item in items: if item.is_section: yield from _rubrique_par_page(item.children, rubrique or item.title) elif item.is_page: yield item.file.src_uri, rubrique def on_nav(nav, config, files): """Retient les pages de la navigation, et sous quelle rubrique elles vivent. 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) _rubriques.clear() _rubriques.update(_rubrique_par_page(nav.items)) 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, fichiers: list[str]) -> None: """Pose l'en-tête CORS sur les seuls fichiers qui en ont besoin. Ils sont lus 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 — et n'est écrit que pour les fichiers qui n'y figurent pas encore. """ fichier = site_dir / ".htaccess" existant = fichier.read_text(encoding="utf-8") if fichier.exists() else "" manquants = [nom for nom in fichiers if nom not in existant] if not manquants: return bloc = "\n" for nom in manquants: bloc += ( f' \n' ' Header set Access-Control-Allow-Origin "*"\n' " \n" ) bloc += "\n" 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) pages = [] 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 pages.append({ "titre": page["titre"], "url": page["url"], "date": creee if nouveau else modifiee, "nouveau": nouveau, "rubrique": _rubriques.get(src_uri, ""), }) if not pages: log.info("derniers-articles : aucune page datée, fichier non écrit") return pages.sort(key=lambda page: page["date"], reverse=True) genere = datetime.now(timezone.utc).replace(microsecond=0).isoformat() # L'accueil lit ce fichier depuis longtemps : il garde ses quatre clés, sans la # rubrique dont il n'a que faire. recents = [ {clef: page[clef] for clef in ("titre", "url", "date", "nouveau")} for page in pages[:NB_MAX] ] ecrits = { FICHIER_RECENTS: {"genere": genere, "articles": recents}, FICHIER_TOUTES: {"genere": genere, "pages": pages}, } try: for nom, contenu in ecrits.items(): texte = json.dumps(contenu, ensure_ascii=False, indent=2) + "\n" (site_dir / nom).write_text(texte, encoding="utf-8") _autoriser_la_lecture_croisee(site_dir, list(ecrits)) except OSError as erreur: log.info("derniers-articles : écriture impossible (%s), fichier non écrit", erreur) return log.info("derniers-articles : %d page(s) publiée(s) dans %s, %d dans %s", len(pages), FICHIER_TOUTES, len(recents), FICHIER_RECENTS)