derniers-articles.json s'arrête à vingt entrées : ce qu'il faut pour la section de l'accueil, trop peu pour la page des mises à jour d'alpinux.org, qui veut montrer tout ce que le wiki publie. Le build écrit donc un second fichier, toutes-les-pages.json, sans plafond et avec la rubrique de chaque page — la section de premier niveau de la navigation, celle à laquelle un lecteur range les choses. Un seul parcours de l'historique git produit les deux : le second est la liste entière, le premier son début. derniers-articles.json ne bouge pas d'un octet dans sa forme — mêmes quatre clés, même ordre — pour que l'accueil continue de le lire sans rien savoir de tout ceci. L'en-tête CORS couvre maintenant les deux fichiers, et seulement eux ; le bloc n'est écrit que pour ceux qui manquent au .htaccess, de sorte qu'un second build ne le duplique pas. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JYfYUZRcJmmEWJwAxZb4o2
235 lines
8 KiB
Python
235 lines
8 KiB
Python
"""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 = "<IfModule mod_headers.c>\n"
|
|
for nom in manquants:
|
|
bloc += (
|
|
f' <Files "{nom}">\n'
|
|
' Header set Access-Control-Allow-Origin "*"\n'
|
|
" </Files>\n"
|
|
)
|
|
bloc += "</IfModule>\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)
|