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 ;
|
- `description` est un résumé court, rédigé manuellement lorsqu’il est utile ;
|
||||||
- `draft` détermine si la page est publiée.
|
- `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` :
|
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
|
./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
|
## 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
|
./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
|
## 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/new-content.py` crée les brouillons ;
|
||||||
- `scripts/update-content.py` horodate les modifications substantielles ;
|
- `scripts/update-content.py` horodate les modifications substantielles ;
|
||||||
- `scripts/check-content.py` valide les contenus ;
|
- `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.
|
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 = {
|
SECTION_ALIASES = {
|
||||||
"article": "articles",
|
"article": "articles",
|
||||||
|
|||||||
+38
-90
@@ -4,134 +4,82 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
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
|
FRONT_MATTER_RE = re.compile(
|
||||||
CONTENT_DIR = ROOT_DIR / "src" / "content"
|
r"\A\+\+\+\r?\n(?P<body>.*?)(?:\r?\n\+\+\+(?=\r?\n|\Z))",
|
||||||
TIMEZONE_NAME = "Europe/Paris"
|
re.DOTALL,
|
||||||
|
)
|
||||||
UPDATED_RE = re.compile(r"\s*(?:#\s*)?updated\s*=.*")
|
UPDATED_RE = re.compile(r"(?m)^(?:#\s*)?updated\s*=.*?(?=\r?$)")
|
||||||
DATE_RE = re.compile(r"\s*date\s*=.*")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
description="Horodater la dernière modification substantielle d’une fiche."
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"fiche",
|
"fiche",
|
||||||
help=(
|
help="chemin depuis la racine du dépôt ou depuis src/content/",
|
||||||
"chemin depuis la racine du dépôt ou depuis src/content/ "
|
|
||||||
"(par exemple vocabulaire/bītum.md)"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def current_timestamp() -> str:
|
def resolve_path(value: str) -> Path:
|
||||||
try:
|
path = Path(value).expanduser()
|
||||||
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")
|
|
||||||
|
|
||||||
|
if not path.is_absolute():
|
||||||
def resolve_content_path(value: str) -> Path:
|
path = ROOT / path if path.parts[:2] == ("src", "content") else CONTENT / 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
|
|
||||||
|
|
||||||
path = path.resolve()
|
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():
|
if not path.is_file():
|
||||||
raise ValueError(f"fichier introuvable : {path}")
|
raise ValueError(f"fichier introuvable : {path}")
|
||||||
if path.suffix != ".md" or path.name == "_index.md":
|
if path.suffix != ".md" or path.name == "_index.md":
|
||||||
raise ValueError("le chemin doit désigner une fiche Markdown, pas un index")
|
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
|
return path
|
||||||
|
|
||||||
|
|
||||||
def update_front_matter(path: Path, timestamp: str) -> None:
|
def update_timestamp(path: Path) -> str:
|
||||||
try:
|
text = path.read_bytes().decode("utf-8")
|
||||||
text = path.read_text(encoding="utf-8")
|
front_matter = FRONT_MATTER_RE.match(text)
|
||||||
except (OSError, UnicodeDecodeError) as exc:
|
|
||||||
raise ValueError(f"lecture impossible ({exc})") from exc
|
|
||||||
|
|
||||||
lines = text.splitlines(keepends=True)
|
if front_matter is None:
|
||||||
if not lines or lines[0].rstrip("\r\n") != "+++":
|
raise ValueError("front matter TOML délimité par +++ absent")
|
||||||
raise ValueError("front matter TOML initial absent")
|
|
||||||
|
|
||||||
try:
|
matches = list(UPDATED_RE.finditer(front_matter.group("body")))
|
||||||
closing_index = next(
|
if len(matches) != 1:
|
||||||
index
|
raise ValueError("la fiche doit contenir exactement une ligne updated")
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
timestamp = datetime.now(ZoneInfo("Europe/Paris")).isoformat(timespec="seconds")
|
||||||
tomllib.loads("".join(lines[1:closing_index]))
|
|
||||||
except tomllib.TOMLDecodeError as exc:
|
|
||||||
raise ValueError(f"front matter TOML invalide ({exc})") from exc
|
|
||||||
|
|
||||||
updated_indexes = [
|
match = matches[0]
|
||||||
index
|
offset = front_matter.start("body")
|
||||||
for index in range(1, closing_index)
|
start = offset + match.start()
|
||||||
if UPDATED_RE.fullmatch(lines[index].rstrip("\r\n")) is not None
|
end = offset + match.end()
|
||||||
]
|
|
||||||
if len(updated_indexes) > 1:
|
|
||||||
raise ValueError("plusieurs champs updated sont présents dans le front matter")
|
|
||||||
|
|
||||||
newline = "\r\n" if lines[0].endswith("\r\n") else "\n"
|
path.write_bytes(
|
||||||
updated_line = f"updated = {timestamp}{newline}"
|
(text[:start] + f"updated = {timestamp}" + text[end:]).encode("utf-8")
|
||||||
|
)
|
||||||
if updated_indexes:
|
return timestamp
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
path = resolve_content_path(args.fiche)
|
path = resolve_path(args.fiche)
|
||||||
timestamp = current_timestamp()
|
timestamp = update_timestamp(path)
|
||||||
update_front_matter(path, timestamp)
|
except (OSError, UnicodeError, ValueError, ZoneInfoNotFoundError) as exc:
|
||||||
except (ValueError, RuntimeError) as exc:
|
|
||||||
print(f"Erreur : {exc}", file=sys.stderr)
|
print(f"Erreur : {exc}", file=sys.stderr)
|
||||||
return 1
|
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}")
|
print(f"updated = {timestamp}")
|
||||||
return 0
|
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.
|
*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>
|
<dt>Nature grammaticale</dt>
|
||||||
<dd>{{ macros::taxonomy_links(terms=page.taxonomies.natures, kind="natures") }}</dd>
|
<dd>{{ macros::taxonomy_links(terms=page.taxonomies.natures, kind="natures") }}</dd>
|
||||||
|
|
||||||
{# Tera n’accepte pas les tableaux imbriqués dans un littéral set.
|
{# 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. #}
|
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_field(extra=page.extra, key="meaning", label="Sens") }}
|
||||||
{{ macros::metadata_list(extra=page.extra, key="logograms", label="Logogrammes", lang="sux-Latn") }}
|
{{ macros::metadata_list(extra=page.extra, key="logograms", label="Logogrammes", lang="sux-Latn") }}
|
||||||
{{ macros::metadata_field(extra=page.extra, key="gender", label="Genre") }}
|
{{ macros::metadata_field(extra=page.extra, key="gender", label="Genre") }}
|
||||||
|
|||||||
Reference in New Issue
Block a user