From 0166436000ec6b69f8e3eecdb985380d1e3c45c2 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Sun, 20 Sep 2026 00:22:00 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Publier=20la=20liste=20des=20pages=20r?= =?UTF-8?q?=C3=A9cemment=20mises=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La page d'accueil d'alpinux.org affiche les prochaines dates ; elle n'a aucun moyen de savoir ce qui bouge dans le wiki. Le build écrit désormais « derniers-articles.json » à la racine du site : les vingt dernières pages créées ou modifiées, titre, URL et date. Les dates viennent de git, avec suivi des renommages : déplacer un article ne le fait pas remonter dans la liste, seule une modification de son texte compte. Les pages absentes de la navigation — les redirections laissées derrière un article déplacé — sont écartées. Le hook dépose aussi un .htaccess limité à ce fichier : sans l'en-tête Access-Control-Allow-Origin, le navigateur refuse à alpinux.org de lire une réponse venue de wiki.alpinux.org. En cas de souci — pas d'historique git, git absent — le hook n'écrit rien et le build continue : le site reste publiable et l'accueil masque simplement sa section wiki. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ae3qPEPRrNkEY5LwcTTKc1 --- docs/technique/deploiement-wiki.md | 31 ++++++ hooks/derniers_articles.py | 170 +++++++++++++++++++++++++++++ mkdocs.yml | 3 + 3 files changed, 204 insertions(+) create mode 100644 hooks/derniers_articles.py diff --git a/docs/technique/deploiement-wiki.md b/docs/technique/deploiement-wiki.md index c2bc2de..dd0e206 100644 --- a/docs/technique/deploiement-wiki.md +++ b/docs/technique/deploiement-wiki.md @@ -181,6 +181,36 @@ 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. + +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 +242,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` | diff --git a/hooks/derniers_articles.py b/hooks/derniers_articles.py new file mode 100644 index 0000000..b2c8547 --- /dev/null +++ b/hooks/derniers_articles.py @@ -0,0 +1,170 @@ +"""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). +""" + +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 _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.warning("derniers-articles : historique git illisible (%s), fichier non écrit", erreur) + return + + 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.warning("derniers-articles : aucune page datée, fichier non écrit") + return + + articles.sort(key=lambda a: a["date"], reverse=True) + articles = articles[:NB_MAX] + + (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( + "\n" + f' \n' + ' Header set Access-Control-Allow-Origin "*"\n' + " \n" + "\n", + encoding="utf-8", + ) + + log.info("derniers-articles : %d article(s) publié(s) dans %s", len(articles), FICHIER) diff --git a/mkdocs.yml b/mkdocs.yml index d423571..457d570 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,6 +29,9 @@ theme: icon: repo: fontawesome/brands/git-alt +hooks: + - hooks/derniers_articles.py + extra_css: - stylesheets/alpinux.css -- 2.45.2 From ad1f459659d470e3b85427068a86d143d476d994 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Sun, 20 Sep 2026 00:29:56 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Emp=C3=AAcher=20le=20hook=20des=20derniers?= =?UTF-8?q?=20articles=20de=20bloquer=20la=20publication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le hook promettait de ne jamais faire échouer le build. En pratique il journalisait ses échecs en « warning », et le déploiement construit avec « --strict » : un dépôt sans historique git suffisait à obtenir « Aborted with 1 warnings in strict mode » — plus aucune publication du wiki, pour un fichier d'agrément. Ses messages passent donc en « info », l'écriture des deux fichiers est protégée à son tour, et un clone superficiel est détecté et signalé : ses dates seraient toutes identiques. Vérifié dans les deux cas : avec historique, vingt articles publiés ; sans dépôt git, build réussi et fichier non écrit. Documenté au passage que le .htaccess du DocumentRoot est désormais réécrit à chaque déploiement, rsync --delete recopiant tout le staging. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BE4rvHVETRoWnYNDTGssdo --- docs/technique/deploiement-wiki.md | 15 +++++++- hooks/derniers_articles.py | 59 ++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/docs/technique/deploiement-wiki.md b/docs/technique/deploiement-wiki.md index dd0e206..bd3d784 100644 --- a/docs/technique/deploiement-wiki.md +++ b/docs/technique/deploiement-wiki.md @@ -198,7 +198,20 @@ choses à savoir : - **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. + 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 diff --git a/hooks/derniers_articles.py b/hooks/derniers_articles.py index b2c8547..7cf581d 100644 --- a/hooks/derniers_articles.py +++ b/hooks/derniers_articles.py @@ -11,6 +11,10 @@ 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 @@ -62,6 +66,14 @@ def _git(depot: Path, *args: str) -> str: ).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. @@ -115,9 +127,12 @@ def on_post_build(config): try: modifiees, creees = _historique(depot, dossier) except (OSError, subprocess.SubprocessError) as erreur: - log.warning("derniers-articles : historique git illisible (%s), fichier non écrit", 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 = [] @@ -141,30 +156,34 @@ def on_post_build(config): }) if not articles: - log.warning("derniers-articles : aucune page datée, fichier non écrit") + 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] - (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", - ) + 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( - "\n" - f' \n' - ' Header set Access-Control-Allow-Origin "*"\n' - " \n" - "\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( + "\n" + f' \n' + ' Header set Access-Control-Allow-Origin "*"\n' + " \n" + "\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) -- 2.45.2