Publier la liste des pages récemment mises à jour #8

Open
Cédric A5L wants to merge 2 commits from accueil-derniers-articles into main
3 changed files with 236 additions and 0 deletions

View file

@ -181,6 +181,49 @@ Une exécution réussie encadre le build par une ligne `Deploy started` et une l
---
## Ce que le build publie pour la page d'accueil
Chaque build écrit, à la racine du site, un fichier
**[`derniers-articles.json`](https://wiki.alpinux.org/derniers-articles.json)** : les
vingt dernières pages créées ou modifiées, avec leur titre, leur URL et la date du commit
qui a touché leur contenu. C'est ce fichier que lit la page d'accueil
[alpinux.org](https://alpinux.org) pour afficher « Le wiki, fraîchement mis à jour ».
Il est produit par le hook `hooks/derniers_articles.py`, déclaré dans `mkdocs.yml`. Trois
choses à savoir :
- **Un déplacement n'est pas une mise à jour.** Les dates viennent de `git log` avec suivi
des renommages : déplacer `guides/truc.md` ne fait pas remonter l'article en tête de
liste, seule une modification de son texte le fait.
- **Seules les pages de la navigation sont annoncées.** Une page hors `nav` — typiquement
la redirection laissée derrière un article déplacé — n'apparaît jamais dans la liste.
- **Le hook n'échoue jamais.** Sans historique git, il n'écrit rien et le build continue ;
l'accueil d'alpinux.org masque alors simplement sa section wiki. Ses messages sont pour
cela de niveau *info* et jamais *warning* : le déploiement construit avec `--strict`, où
un seul avertissement suffit à interrompre le build et donc la publication.
!!! warning "Deux conditions côté serveur"
**Le clone doit avoir son historique.** Un clone `--depth 1` ne porte qu'un commit :
toutes les pages sembleraient modifiées le même jour. Le hook le détecte et le
signale dans le journal du build, sans rien casser.
**Le `.htaccess` du DocumentRoot est réécrit à chaque déploiement.** Le hook le
dépose dans le staging, que `rsync --delete` recopie intégralement : un `.htaccess`
qui aurait été posé à la main sur le serveur serait effacé au premier build. S'il
faut d'autres règles Apache, ajoutez-les dans le hook — c'est désormais lui qui fait
foi.
Le même hook dépose un `.htaccess` d'une ligne utile : il autorise les autres sous-domaines
à lire ce seul fichier JSON (`Access-Control-Allow-Origin`). Sans lui, le navigateur
refuserait à alpinux.org le droit de lire une réponse venue de wiki.alpinux.org.
```bash
# vérifier ce que le dernier build a publié
curl -s https://wiki.alpinux.org/derniers-articles.json | head -20
```
---
## En cas d'erreur de build
Si `mkdocs build` échoue, le site en ligne **n'est pas modifié** — l'ancien contenu reste en place. Lisez le message d'erreur : il indique généralement le fichier et la ligne problématiques.
@ -212,4 +255,5 @@ Ouvrez [http://localhost:8000](http://localhost:8000) — MkDocs recharge automa
| Vérifier le build avant de pousser | `mkdocs build --strict -d /tmp/wiki-build` |
| Déployer à la main (webhook en panne) | `deploy-wiki.sh`, sur le serveur |
| Voir le journal des déploiements | `tail -20 /var/log/wiki-deploy.log` |
| Vérifier la liste envoyée à l'accueil | `curl -s https://wiki.alpinux.org/derniers-articles.json` |
| Régénérer les fichiers du logo (poste local) | `python3 scripts/build-assets.py` |

189
hooks/derniers_articles.py Normal file
View file

@ -0,0 +1,189 @@
"""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(
"<IfModule mod_headers.c>\n"
f' <Files "{FICHIER}">\n'
' Header set Access-Control-Allow-Origin "*"\n'
" </Files>\n"
"</IfModule>\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)

View file

@ -29,6 +29,9 @@ theme:
icon:
repo: fontawesome/brands/git-alt
hooks:
- hooks/derniers_articles.py
extra_css:
- stylesheets/alpinux.css