Publier la liste des pages récemment mises à jour #8
3 changed files with 236 additions and 0 deletions
|
|
@ -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
|
## 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.
|
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` |
|
| 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 |
|
| 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` |
|
| 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` |
|
| Régénérer les fichiers du logo (poste local) | `python3 scripts/build-assets.py` |
|
||||||
|
|
|
||||||
189
hooks/derniers_articles.py
Normal file
189
hooks/derniers_articles.py
Normal 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)
|
||||||
|
|
@ -29,6 +29,9 @@ theme:
|
||||||
icon:
|
icon:
|
||||||
repo: fontawesome/brands/git-alt
|
repo: fontawesome/brands/git-alt
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
- hooks/derniers_articles.py
|
||||||
|
|
||||||
extra_css:
|
extra_css:
|
||||||
- stylesheets/alpinux.css
|
- stylesheets/alpinux.css
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue