Simplification du script d'horodatage et quelques corrections mineures

This commit is contained in:
julien
2026-08-05 18:29:51 +02:00
parent 65642a5983
commit 81616e11e3
6 changed files with 46 additions and 98 deletions
+2 -2
View File
@@ -94,7 +94,7 @@ Les tables propres à chaque type de contenu viennent ensuite dans le même fron
- `description` est un résumé court, rédigé manuellement lorsquil est utile ;
- `draft` détermine si la page est publiée.
`date` est un horodatage TOML écrit sans guillemets dans le fuseau `Europe/Paris`. La ligne commentée `# updated = YYYY-MM-DDTHH:MM:SS+HH:MM` est un repère inactif. Après une modification substantielle, `update-content.py` lactive, remplace sa valeur ou insère `updated` après `date` lorsquil est absent.
`date` est un horodatage TOML écrit sans guillemets dans le fuseau `Europe/Paris`. La ligne commentée `# updated = YYYY-MM-DDTHH:MM:SS+HH:MM` est un repère inactif. Après une modification substantielle, `update-content.py` remplace lunique ligne `updated`, quelle soit commentée ou active, par lhorodatage courant. La fiche doit toujours conserver exactement une telle ligne.
Le résultat utilise le même format que `date` :
@@ -180,7 +180,7 @@ Le chemin complet se sélectionne rapidement avec la touche Tab du shell. Une fo
./scripts/update-content.py vocabulaire/bītum.md
```
Le script refuse les index, les fichiers extérieurs à `src/content/` et les sections inconnues. Il active le repère commenté, remplace lhorodatage déjà présent ou insère `updated` après `date` lorsquil est absent, sans modifier le reste du fichier.
Le script refuse les index et les fichiers extérieurs à `src/content/`. Il exige exactement une ligne `updated`, commentée ou active, puis la remplace sans modifier le reste du fichier.
## Taxonomies
+2 -2
View File
@@ -117,7 +117,7 @@ Ce chemin se complète directement avec la touche Tab du shell. La forme abrég
./scripts/update-content.py vocabulaire/bītum.md
```
Le script active le repère commenté, remplace lhorodatage existant ou insère `updated` après `date` lorsquil est absent.
Le script remplace lunique ligne `updated`, quelle soit commentée ou active. La fiche doit donc toujours conserver cette ligne.
## Publier un contenu
@@ -180,7 +180,7 @@ Une erreur renvoie un code de sortie non nul et bloque également la constructio
- `scripts/new-content.py` crée les brouillons ;
- `scripts/update-content.py` horodate les modifications substantielles ;
- `scripts/check-content.py` valide les contenus ;
- `scripts/_schema.py` contient les définitions communes aux outils de gestion des contenus.
- `scripts/_schema.py` contient les définitions communes au générateur et au validateur.
Les chemins et les taxonomies utilisent la stratégie de slugification `safe` de Zola. Les diacritiques sont donc conservés : des lectures comme `tim` et `ṭim` restent distinctes.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Définitions communes aux outils de gestion des contenus."""
"""Définitions communes au générateur et au validateur de contenus."""
SECTION_ALIASES = {
"article": "articles",
+38 -90
View File
@@ -4,134 +4,82 @@
import argparse
import re
import sys
import tomllib
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from _schema import KNOWN_SECTIONS
ROOT = Path(__file__).resolve().parent.parent
CONTENT = (ROOT / "src" / "content").resolve()
ROOT_DIR = Path(__file__).resolve().parent.parent
CONTENT_DIR = ROOT_DIR / "src" / "content"
TIMEZONE_NAME = "Europe/Paris"
UPDATED_RE = re.compile(r"\s*(?:#\s*)?updated\s*=.*")
DATE_RE = re.compile(r"\s*date\s*=.*")
FRONT_MATTER_RE = re.compile(
r"\A\+\+\+\r?\n(?P<body>.*?)(?:\r?\n\+\+\+(?=\r?\n|\Z))",
re.DOTALL,
)
UPDATED_RE = re.compile(r"(?m)^(?:#\s*)?updated\s*=.*?(?=\r?$)")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Horodater la dernière modification substantielle dune fiche."
)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"fiche",
help=(
"chemin depuis la racine du dépôt ou depuis src/content/ "
"(par exemple vocabulaire/bītum.md)"
),
help="chemin depuis la racine du dépôt ou depuis src/content/",
)
return parser.parse_args()
def current_timestamp() -> str:
try:
timezone = ZoneInfo(TIMEZONE_NAME)
except ZoneInfoNotFoundError as exc:
raise RuntimeError(
f"fuseau {TIMEZONE_NAME} indisponible ; installer les données de fuseaux horaires"
) from exc
return datetime.now(timezone).isoformat(timespec="seconds")
def resolve_path(value: str) -> Path:
path = Path(value).expanduser()
def resolve_content_path(value: str) -> Path:
supplied = Path(value).expanduser()
if supplied.is_absolute():
path = supplied
elif supplied.parts[:2] == ("src", "content"):
path = ROOT_DIR / supplied
else:
path = CONTENT_DIR / supplied
if not path.is_absolute():
path = ROOT / path if path.parts[:2] == ("src", "content") else CONTENT / path
path = path.resolve()
try:
relative = path.relative_to(CONTENT_DIR.resolve())
except ValueError as exc:
raise ValueError("la fiche doit se trouver dans src/content/") from exc
if not path.is_relative_to(CONTENT):
raise ValueError("la fiche doit se trouver dans src/content/")
if not path.is_file():
raise ValueError(f"fichier introuvable : {path}")
if path.suffix != ".md" or path.name == "_index.md":
raise ValueError("le chemin doit désigner une fiche Markdown, pas un index")
if len(relative.parts) < 2 or relative.parts[0] not in KNOWN_SECTIONS:
raise ValueError("la fiche doit appartenir à une section éditoriale connue")
return path
def update_front_matter(path: Path, timestamp: str) -> None:
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
raise ValueError(f"lecture impossible ({exc})") from exc
def update_timestamp(path: Path) -> str:
text = path.read_bytes().decode("utf-8")
front_matter = FRONT_MATTER_RE.match(text)
lines = text.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "+++":
raise ValueError("front matter TOML initial absent")
if front_matter is None:
raise ValueError("front matter TOML délimité par +++ absent")
try:
closing_index = next(
index
for index, line in enumerate(lines[1:], start=1)
if line.rstrip("\r\n") == "+++"
)
except StopIteration as exc:
raise ValueError("délimiteur final +++ absent") from exc
matches = list(UPDATED_RE.finditer(front_matter.group("body")))
if len(matches) != 1:
raise ValueError("la fiche doit contenir exactement une ligne updated")
try:
tomllib.loads("".join(lines[1:closing_index]))
except tomllib.TOMLDecodeError as exc:
raise ValueError(f"front matter TOML invalide ({exc})") from exc
timestamp = datetime.now(ZoneInfo("Europe/Paris")).isoformat(timespec="seconds")
updated_indexes = [
index
for index in range(1, closing_index)
if UPDATED_RE.fullmatch(lines[index].rstrip("\r\n")) is not None
]
if len(updated_indexes) > 1:
raise ValueError("plusieurs champs updated sont présents dans le front matter")
match = matches[0]
offset = front_matter.start("body")
start = offset + match.start()
end = offset + match.end()
newline = "\r\n" if lines[0].endswith("\r\n") else "\n"
updated_line = f"updated = {timestamp}{newline}"
if updated_indexes:
lines[updated_indexes[0]] = updated_line
else:
try:
date_index = next(
index
for index in range(1, closing_index)
if DATE_RE.fullmatch(lines[index].rstrip("\r\n")) is not None
)
except StopIteration as exc:
raise ValueError("champ date absent du front matter") from exc
lines.insert(date_index + 1, updated_line)
try:
path.write_text("".join(lines), encoding="utf-8")
except OSError as exc:
raise ValueError(f"écriture impossible ({exc})") from exc
path.write_bytes(
(text[:start] + f"updated = {timestamp}" + text[end:]).encode("utf-8")
)
return timestamp
def main() -> int:
args = parse_args()
try:
path = resolve_content_path(args.fiche)
timestamp = current_timestamp()
update_front_matter(path, timestamp)
except (ValueError, RuntimeError) as exc:
path = resolve_path(args.fiche)
timestamp = update_timestamp(path)
except (OSError, UnicodeError, ValueError, ZoneInfoNotFoundError) as exc:
print(f"Erreur : {exc}", file=sys.stderr)
return 1
print(f"Fiche mise à jour : {path.relative_to(ROOT_DIR)}")
print(f"Fiche mise à jour : {path.relative_to(ROOT)}")
print(f"updated = {timestamp}")
return 0
+1 -1
View File
@@ -21,4 +21,4 @@ natures = ["pronom"]
*anāku* est le pronom personnel indépendant de première personne du singulier, commun aux deux genres.
Il semploie comme sujet dune proposition non verbale. Dans une proposition verbale, où la personne est déjà indiquée par la forme du verbe, il peut être ajouté pour insister sur le sujet.
Il semploie comme sujet dune proposition non verbale. Dans une proposition verbale, où la personne est déjà indiquée par la forme du verbe, il peut être ajouté pour insister sur le sujet.
+2 -2
View File
@@ -30,8 +30,8 @@
<dt>Nature grammaticale</dt>
<dd>{{ macros::taxonomy_links(terms=page.taxonomies.natures, kind="natures") }}</dd>
{# Tera naccepte pas les tableaux imbriqués dans un littéral set.
Les champs sont donc déclarés explicitement et leur balisage est factorisé par macro. #}
{# Tera 1 naccepte pas les tableaux imbriqués dans un littéral set.
Les champs sont donc déclarés explicitement et leur balisage est factorisé par macro. #}
{{ macros::metadata_field(extra=page.extra, key="meaning", label="Sens") }}
{{ macros::metadata_list(extra=page.extra, key="logograms", label="Logogrammes", lang="sux-Latn") }}
{{ macros::metadata_field(extra=page.extra, key="gender", label="Genre") }}