Simplification du script d'horodatage et quelques corrections mineures
This commit is contained in:
+2
-2
@@ -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 lorsqu’il 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` l’active, remplace sa valeur ou insère `updated` après `date` lorsqu’il 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 l’unique ligne `updated`, qu’elle soit commentée ou active, par l’horodatage 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 l’horodatage déjà présent ou insère `updated` après `date` lorsqu’il 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
|
||||
|
||||
|
||||
@@ -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 l’horodatage existant ou insère `updated` après `date` lorsqu’il est absent.
|
||||
Le script remplace l’unique ligne `updated`, qu’elle 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
@@ -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
@@ -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 d’une 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
|
||||
|
||||
|
||||
@@ -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 s’emploie comme sujet d’une 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 s’emploie comme sujet d’une 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.
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
<dt>Nature grammaticale</dt>
|
||||
<dd>{{ macros::taxonomy_links(terms=page.taxonomies.natures, kind="natures") }}</dd>
|
||||
|
||||
{# Tera n’accepte 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 n’accepte 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") }}
|
||||
|
||||
Reference in New Issue
Block a user