Meilleurs scripts
This commit is contained in:
+25
-22
@@ -168,7 +168,7 @@ La forme abrégée, relative à `src/content/`, est également acceptée :
|
||||
./scripts/update-content.py vocabulaire/bītum.md
|
||||
```
|
||||
|
||||
Le chemin complet peut être saisi rapidement avec l’autocomplétion du shell. Le script refuse les index et les fichiers extérieurs à `src/content/`, puis remplace l’unique ligne `updated` par l’heure courante de `Europe/Paris` :
|
||||
Le chemin complet peut être saisi rapidement avec l’autocomplétion du shell. Le script accepte uniquement les fiches Markdown placées dans une section connue ; il refuse donc les index, les pages situées directement dans `src/content/` et les fichiers extérieurs à ce répertoire. Il remplace ensuite l’unique ligne `updated` par l’heure courante de `Europe/Paris` :
|
||||
|
||||
```toml
|
||||
updated = 2026-08-05T16:47:00+02:00
|
||||
@@ -186,7 +186,7 @@ La métadescription HTML utilise alors, en dernier recours, la description gén
|
||||
|
||||
## Brouillons et publication
|
||||
|
||||
Un brouillon peut rester incomplet. Pour publier une fiche :
|
||||
Un brouillon peut conserver des valeurs vides, mais tous les champs de son modèle restent présents afin que sa structure puisse être vérifiée dès sa création. Pour publier une fiche :
|
||||
|
||||
1. compléter les données disponibles sans supprimer les champs de son modèle ;
|
||||
2. rédiger le corps Markdown ;
|
||||
@@ -237,7 +237,7 @@ natures = ["nature"]
|
||||
+++
|
||||
```
|
||||
|
||||
`meaning` contient le sens principal ou une courte série de sens. Il doit être renseigné avant publication.
|
||||
`meaning` contient le sens principal ou une courte série de sens. Il est normalement renseigné avant publication, mais peut rester vide pendant la rédaction.
|
||||
|
||||
`logograms` reste toujours un tableau de chaînes :
|
||||
|
||||
@@ -259,7 +259,7 @@ Chaque nature ajoute les champs suivants, dans l’ordre produit par le généra
|
||||
| Particule | `function` |
|
||||
| Numéral | `numeral_type`, `value`, `gender`, `feminine` |
|
||||
|
||||
Tous les champs simples restent des chaînes, même lorsqu’ils sont vides. Une fiche publiée doit contenir exactement les champs de sa nature. Pour un verbe, `stem` doit être renseigné avant publication. Le titre fournit déjà l’infinitif et ne le répète donc pas dans `[extra]`.
|
||||
Tous les champs simples restent des chaînes, même lorsqu’ils sont vides. Chaque fiche, brouillon compris, doit conserver tous les champs prévus pour sa nature. Le validateur contrôle leur présence et leur type, sans imposer de valeur non vide ni refuser d’éventuels champs supplémentaires. Le titre fournit déjà l’infinitif et ne le répète donc pas dans `[extra]`.
|
||||
|
||||
### Sens des principaux champs
|
||||
|
||||
@@ -324,7 +324,7 @@ lectures = []
|
||||
- `mzl` reprend exactement les trois chiffres du nom de fichier ;
|
||||
- `lectures` contient les lectures translittérées.
|
||||
|
||||
Le nom `mzl-XXX.md` est contrôlé même lorsque la fiche est en brouillon. Avant publication, le titre, le glyphe et le numéro MZL doivent être renseignés, et le numéro doit correspondre au nom du fichier.
|
||||
Le générateur forme automatiquement le nom `mzl-XXX.md` et renseigne le numéro MZL correspondant. Le validateur vérifie ensuite que `sign`, `mzl` et `lectures` restent présents avec le type attendu, même lorsque la fiche est en brouillon. Il n’impose pas de valeur non vide et ne compare pas le numéro au nom du fichier.
|
||||
|
||||
Le corps peut accueillir, selon le besoin :
|
||||
|
||||
@@ -422,31 +422,34 @@ Ils sont ajoutés lorsqu’ils apportent un contexte réel : occurrence dans un
|
||||
|
||||
## Ce que contrôle le validateur
|
||||
|
||||
Pour tous les fichiers Markdown, le validateur vérifie :
|
||||
Le validateur est volontairement limité à la structure des fiches placées dans `articles/`, `grammaire/`, `signes/`, `textes/` et `vocabulaire/`. Les pages situées directement dans `src/content/`, comme la bibliographie et les mentions légales, ainsi que les fichiers `_index.md`, sont entièrement ignorés.
|
||||
|
||||
- l’encodage UTF-8 ;
|
||||
- la normalisation Unicode NFC du contenu et du chemin ;
|
||||
- la présence et la validité du front matter TOML ;
|
||||
- l’appartenance à une section connue lorsque le fichier est placé dans un sous-répertoire de `src/content/`.
|
||||
Pour chaque fiche, il vérifie que le fichier peut être lu en UTF-8, que son front matter TOML est valide et qu’il contient exactement une ligne `updated`, commentée ou active. Il contrôle ensuite la présence et le type des champs communs :
|
||||
|
||||
Pour les fichiers `_index.md`, il exige un titre non vide.
|
||||
- `title` et `description` doivent être des chaînes, éventuellement vides ;
|
||||
- `date` doit être un horodatage TOML ;
|
||||
- `draft` doit être un booléen ;
|
||||
- `updated`, lorsqu’il est actif, doit également être un horodatage TOML.
|
||||
|
||||
Pour les autres pages, `draft` doit être un booléen. Les brouillons ne sont pas soumis aux contrôles de complétude, à l’exception du nom des fiches de signes.
|
||||
Les brouillons et les fiches publiées sont contrôlés de la même façon. Le validateur n’évalue pas la qualité éditoriale des valeurs et n’exige donc ni titre, ni sens, ni thème verbal non vide.
|
||||
|
||||
Pour toute page publiée, il exige un titre non vide. Lorsque la page appartient à une section, `date` doit être un horodatage TOML avec fuseau horaire. Si `updated` est actif, il doit respecter le même format. Les pages permanentes placées directement dans `src/content/`, comme la bibliographie et les mentions légales, n’ont pas besoin de date.
|
||||
Pour le vocabulaire, il vérifie en plus :
|
||||
|
||||
Pour une fiche de vocabulaire publiée, il contrôle en plus :
|
||||
|
||||
- une seule nature grammaticale connue ;
|
||||
- la présence exacte des champs prévus pour cette nature ;
|
||||
- la présence d’une seule nature grammaticale connue dans `taxonomies.natures` ;
|
||||
- la présence de tous les champs `[extra]` prévus pour cette nature ;
|
||||
- des chaînes pour les champs simples ;
|
||||
- un tableau de chaînes non vides pour `logograms` ;
|
||||
- un `meaning` non vide ;
|
||||
- un `stem` non vide pour les verbes.
|
||||
- un tableau de chaînes pour `logograms`, qui peut être vide.
|
||||
|
||||
Pour une fiche de signe publiée, il contrôle le glyphe, le numéro MZL sur trois chiffres et sa correspondance avec le nom du fichier.
|
||||
Pour les autres sections, il vérifie les modèles centralisés dans `_schema.py` :
|
||||
|
||||
Zola reste responsable de la construction, des liens internes, des taxonomies et du rendu des templates.
|
||||
- `banner`, `banner_alt` et `banner_credit` pour les articles ;
|
||||
- `themes` pour la grammaire ;
|
||||
- `sign`, `mzl` et `lectures` pour les signes ;
|
||||
- `genres` pour les textes.
|
||||
|
||||
Les tableaux peuvent être vides et les chaînes peuvent être vides. Les champs supplémentaires ne sont pas rejetés. Le validateur ne contrôle ni la forme des noms de fichiers, ni la correspondance du numéro MZL avec le fichier, ni la normalisation Unicode du chemin ou du contenu.
|
||||
|
||||
Zola reste responsable de la construction, des liens internes, des taxonomies et du rendu des templates. Le générateur reste responsable de produire les noms de fichiers attendus et des squelettes complets.
|
||||
|
||||
## Principes de cohérence
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Le résultat est écrit dans `src/public/`, répertoire ignoré par Git.
|
||||
|
||||
## Rédaction des contenus
|
||||
|
||||
Les contenus sont rédigés en Markdown dans `src/content/`. Des scripts permettent de créer les squelettes, d’horodater les modifications substantielles et de vérifier les fiches avant publication.
|
||||
Les contenus sont rédigés en Markdown dans `src/content/`. Des scripts permettent de créer des squelettes complets, d’horodater les modifications substantielles et de vérifier que chaque fiche conserve les champs prévus pour son type.
|
||||
|
||||
Les conventions éditoriales, les commandes et les modèles de chaque type de fiche sont décrits dans le [guide de rédaction](GUIDE_REDACTION.md).
|
||||
|
||||
|
||||
+17
-2
@@ -1,4 +1,6 @@
|
||||
"""Définitions communes au générateur et au validateur de contenus."""
|
||||
"""Schémas et règles structurelles communs aux scripts de fiches."""
|
||||
|
||||
import re
|
||||
|
||||
SECTION_ALIASES = {
|
||||
"article": "articles",
|
||||
@@ -58,4 +60,17 @@ VOCABULARY_FIELDS = {
|
||||
),
|
||||
}
|
||||
|
||||
KNOWN_SECTIONS = frozenset(SECTION_ALIASES.values())
|
||||
SECTION_FIELDS = {
|
||||
"articles": {"extra": ("banner", "banner_alt", "banner_credit")},
|
||||
"grammaire": {"taxonomies": ("themes",)},
|
||||
"signes": {
|
||||
"extra": ("sign", "mzl"),
|
||||
"taxonomies": ("lectures",),
|
||||
},
|
||||
"textes": {"taxonomies": ("genres",)},
|
||||
}
|
||||
|
||||
LIST_FIELDS = frozenset({"logograms", "lectures", "themes", "genres"})
|
||||
|
||||
CONTENT_SECTIONS = frozenset(SECTION_FIELDS) | {"vocabulaire"}
|
||||
UPDATED_LINE_RE = re.compile(r"(?m)^(?:#\s*)?updated\s*=.*?(?=\r?$)")
|
||||
|
||||
+112
-198
@@ -1,21 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vérifie la structure des contenus, le vocabulaire et les signes."""
|
||||
"""Vérifie que chaque fiche contient les champs prévus pour son modèle."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from _schema import KNOWN_SECTIONS, VOCABULARY_FIELDS
|
||||
from _schema import (
|
||||
CONTENT_SECTIONS,
|
||||
LIST_FIELDS,
|
||||
SECTION_FIELDS,
|
||||
UPDATED_LINE_RE,
|
||||
VOCABULARY_FIELDS,
|
||||
)
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
CONTENT_DIR = ROOT_DIR / "src" / "content"
|
||||
|
||||
MZL_FILENAME_RE = re.compile(r"mzl-([0-9]{3})\.md")
|
||||
MZL_VALUE_RE = re.compile(r"[0-9]{3}")
|
||||
COMMON_FIELDS = {
|
||||
"title": str,
|
||||
"date": datetime,
|
||||
"description": str,
|
||||
"draft": bool,
|
||||
}
|
||||
|
||||
|
||||
|
||||
class Validator:
|
||||
@@ -23,10 +32,7 @@ class Validator:
|
||||
self.errors = 0
|
||||
|
||||
def error(self, path: Path | None, message: str) -> None:
|
||||
if path is None:
|
||||
label = ""
|
||||
else:
|
||||
label = f"{path.relative_to(ROOT_DIR).as_posix()} : "
|
||||
label = f"{path.relative_to(ROOT_DIR).as_posix()} : " if path else ""
|
||||
print(f"ERREUR : {label}{message}", file=sys.stderr)
|
||||
self.errors += 1
|
||||
|
||||
@@ -35,24 +41,16 @@ class Validator:
|
||||
return 1 if self.errors else 0
|
||||
|
||||
|
||||
def is_non_empty_string(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def read_front_matter(path: Path, validator: Validator) -> dict[str, Any] | None:
|
||||
def read_front_matter(
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> tuple[dict[str, Any], str] | None:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
validator.error(path, f"fichier non valide en UTF-8 ({exc})")
|
||||
return None
|
||||
except OSError as exc:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
validator.error(path, f"lecture impossible ({exc})")
|
||||
return None
|
||||
|
||||
if not unicodedata.is_normalized("NFC", text):
|
||||
validator.error(path, "contenu non normalisé en Unicode NFC")
|
||||
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0] != "+++":
|
||||
validator.error(path, "front matter TOML initial absent")
|
||||
return None
|
||||
@@ -63,231 +61,147 @@ def read_front_matter(path: Path, validator: Validator) -> dict[str, Any] | None
|
||||
validator.error(path, "délimiteur final +++ absent")
|
||||
return None
|
||||
|
||||
source = "\n".join(lines[1:closing_index])
|
||||
try:
|
||||
return tomllib.loads("\n".join(lines[1:closing_index]))
|
||||
return tomllib.loads(source), source
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
validator.error(path, f"front matter TOML invalide ({exc})")
|
||||
return None
|
||||
|
||||
|
||||
def require_table(
|
||||
metadata: dict[str, Any],
|
||||
key: str,
|
||||
def check_type(
|
||||
value: Any,
|
||||
expected_type: type,
|
||||
label: str,
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> dict[str, Any] | None:
|
||||
value = metadata.get(key)
|
||||
if not isinstance(value, dict):
|
||||
validator.error(path, f"{key} doit être une table TOML")
|
||||
return None
|
||||
return value
|
||||
) -> bool:
|
||||
if isinstance(value, expected_type):
|
||||
return True
|
||||
|
||||
validator.error(path, f"{label} doit être de type {expected_type.__name__}")
|
||||
return False
|
||||
|
||||
|
||||
def require_string(
|
||||
def check_string_list(
|
||||
value: Any,
|
||||
label: str,
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> bool:
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return True
|
||||
|
||||
validator.error(path, f"{label} doit être un tableau de chaînes")
|
||||
return False
|
||||
|
||||
|
||||
def check_fields(
|
||||
metadata: dict[str, Any],
|
||||
key: str,
|
||||
table_name: str,
|
||||
fields: tuple[str, ...],
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> None:
|
||||
if not is_non_empty_string(metadata.get(key)):
|
||||
validator.error(path, f"{key} doit être une chaîne non vide")
|
||||
|
||||
|
||||
def require_timestamp(
|
||||
metadata: dict[str, Any],
|
||||
key: str,
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
*,
|
||||
optional: bool = False,
|
||||
) -> None:
|
||||
value = metadata.get(key)
|
||||
if optional and value is None:
|
||||
table = metadata.get(table_name)
|
||||
if not isinstance(table, dict):
|
||||
validator.error(path, f"{table_name} doit être une table TOML")
|
||||
return
|
||||
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
||||
validator.error(
|
||||
path,
|
||||
f"{key} doit être un horodatage TOML avec fuseau horaire",
|
||||
)
|
||||
|
||||
missing = [field for field in fields if field not in table]
|
||||
if missing:
|
||||
validator.error(path, f"champs {table_name} manquants : {', '.join(missing)}")
|
||||
|
||||
for field in fields:
|
||||
if field not in table:
|
||||
continue
|
||||
|
||||
label = f"{table_name}.{field}"
|
||||
if field in LIST_FIELDS:
|
||||
check_string_list(table[field], label, path, validator)
|
||||
else:
|
||||
check_type(table[field], str, label, path, validator)
|
||||
|
||||
|
||||
def check_structure(
|
||||
path: Path,
|
||||
metadata: dict[str, Any],
|
||||
validator: Validator,
|
||||
) -> tuple[str, bool] | None:
|
||||
relative_path = path.relative_to(CONTENT_DIR)
|
||||
section = relative_path.parts[0] if len(relative_path.parts) > 1 else ""
|
||||
|
||||
if not unicodedata.is_normalized("NFC", relative_path.as_posix()):
|
||||
validator.error(path, "chemin non normalisé en Unicode NFC")
|
||||
|
||||
if section and section not in KNOWN_SECTIONS:
|
||||
validator.error(path, f"section inconnue « {section} »")
|
||||
|
||||
if path.name == "_index.md":
|
||||
require_string(metadata, "title", path, validator)
|
||||
return None
|
||||
|
||||
draft = metadata.get("draft")
|
||||
if not isinstance(draft, bool):
|
||||
validator.error(path, "draft doit être un booléen")
|
||||
return None
|
||||
|
||||
if draft:
|
||||
return section, True
|
||||
|
||||
require_string(metadata, "title", path, validator)
|
||||
if section:
|
||||
require_timestamp(metadata, "date", path, validator)
|
||||
require_timestamp(metadata, "updated", path, validator, optional=True)
|
||||
return section, False
|
||||
|
||||
|
||||
def vocabulary_nature(
|
||||
def check_common_fields(
|
||||
metadata: dict[str, Any],
|
||||
front_matter: str,
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> str | None:
|
||||
taxonomies = require_table(metadata, "taxonomies", path, validator)
|
||||
if taxonomies is None:
|
||||
return None
|
||||
) -> None:
|
||||
for field, expected_type in COMMON_FIELDS.items():
|
||||
if field not in metadata:
|
||||
validator.error(path, f"champ manquant : {field}")
|
||||
else:
|
||||
check_type(metadata[field], expected_type, field, path, validator)
|
||||
|
||||
natures = taxonomies.get("natures")
|
||||
if not isinstance(natures, list) or len(natures) != 1:
|
||||
validator.error(path, "taxonomies.natures doit contenir exactement une nature")
|
||||
return None
|
||||
updated_lines = len(UPDATED_LINE_RE.findall(front_matter))
|
||||
if updated_lines != 1:
|
||||
validator.error(path, "la fiche doit contenir exactement une ligne updated")
|
||||
|
||||
nature = natures[0]
|
||||
if not isinstance(nature, str):
|
||||
validator.error(path, "taxonomies.natures doit contenir une chaîne")
|
||||
return None
|
||||
|
||||
if nature not in VOCABULARY_FIELDS:
|
||||
validator.error(path, f"nature grammaticale inconnue « {nature} »")
|
||||
return None
|
||||
|
||||
return nature
|
||||
if "updated" in metadata:
|
||||
check_type(metadata["updated"], datetime, "updated", path, validator)
|
||||
|
||||
|
||||
def check_vocabulary(
|
||||
path: Path,
|
||||
metadata: dict[str, Any],
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> None:
|
||||
nature = vocabulary_nature(metadata, path, validator)
|
||||
extra = require_table(metadata, "extra", path, validator)
|
||||
if nature is None or extra is None:
|
||||
taxonomies = metadata.get("taxonomies")
|
||||
if not isinstance(taxonomies, dict):
|
||||
validator.error(path, "taxonomies doit être une table TOML")
|
||||
return
|
||||
|
||||
expected_fields = VOCABULARY_FIELDS[nature]
|
||||
expected_keys = set(expected_fields)
|
||||
actual_keys = set(extra)
|
||||
|
||||
missing_fields = [field for field in expected_fields if field not in actual_keys]
|
||||
if missing_fields:
|
||||
validator.error(
|
||||
path,
|
||||
"champs extra manquants pour "
|
||||
f"la nature « {nature} » : {', '.join(missing_fields)}",
|
||||
)
|
||||
|
||||
unexpected_fields = sorted(actual_keys - expected_keys)
|
||||
if unexpected_fields:
|
||||
validator.error(
|
||||
path,
|
||||
"champs extra inattendus pour "
|
||||
f"la nature « {nature} » : {', '.join(unexpected_fields)}",
|
||||
)
|
||||
|
||||
for field in expected_fields:
|
||||
if field not in extra:
|
||||
continue
|
||||
|
||||
value = extra[field]
|
||||
if field == "logograms":
|
||||
if not isinstance(value, list) or not all(
|
||||
is_non_empty_string(item) for item in value
|
||||
):
|
||||
validator.error(
|
||||
path,
|
||||
"extra.logograms doit être un tableau de chaînes non vides",
|
||||
)
|
||||
elif not isinstance(value, str):
|
||||
validator.error(path, f"extra.{field} doit être une chaîne")
|
||||
|
||||
required_fields = ("meaning", "stem") if nature == "verbe" else ("meaning",)
|
||||
for field in required_fields:
|
||||
value = extra.get(field)
|
||||
if isinstance(value, str) and not value.strip():
|
||||
validator.error(path, f"extra.{field} doit être une chaîne non vide")
|
||||
|
||||
|
||||
def filename_mzl(path: Path, validator: Validator) -> str | None:
|
||||
match = MZL_FILENAME_RE.fullmatch(path.name)
|
||||
if match is None:
|
||||
validator.error(path, "le nom du fichier doit suivre le format mzl-XXX.md")
|
||||
return None
|
||||
|
||||
value = match.group(1)
|
||||
if int(value) == 0:
|
||||
validator.error(path, "le numéro MZL doit être compris entre 001 et 999")
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def check_sign(
|
||||
path: Path,
|
||||
metadata: dict[str, Any],
|
||||
expected_mzl: str | None,
|
||||
validator: Validator,
|
||||
) -> None:
|
||||
extra = require_table(metadata, "extra", path, validator)
|
||||
if extra is None:
|
||||
if "natures" not in taxonomies:
|
||||
validator.error(path, "champ taxonomies manquant : natures")
|
||||
return
|
||||
|
||||
require_string(extra, "sign", path, validator)
|
||||
natures = taxonomies["natures"]
|
||||
if not check_string_list(natures, "taxonomies.natures", path, validator):
|
||||
return
|
||||
|
||||
declared_mzl = extra.get("mzl")
|
||||
if not isinstance(declared_mzl, str) or MZL_VALUE_RE.fullmatch(declared_mzl) is None:
|
||||
validator.error(path, "extra.mzl doit être une chaîne de trois chiffres ASCII")
|
||||
if len(natures) != 1:
|
||||
validator.error(path, "taxonomies.natures doit contenir exactement une nature")
|
||||
return
|
||||
if int(declared_mzl) == 0:
|
||||
validator.error(path, "extra.mzl doit être compris entre 001 et 999")
|
||||
|
||||
nature = natures[0]
|
||||
if nature not in VOCABULARY_FIELDS:
|
||||
validator.error(path, f"nature grammaticale inconnue : {nature!r}")
|
||||
return
|
||||
if expected_mzl is not None and declared_mzl != expected_mzl:
|
||||
validator.error(
|
||||
path,
|
||||
f'extra.mzl vaut "{declared_mzl}", mais le fichier indique MZL {expected_mzl}',
|
||||
)
|
||||
|
||||
check_fields(metadata, "extra", VOCABULARY_FIELDS[nature], path, validator)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
validator = Validator()
|
||||
|
||||
if not CONTENT_DIR.is_dir():
|
||||
validator.error(None, f"répertoire introuvable : {CONTENT_DIR}")
|
||||
return validator.finish()
|
||||
|
||||
print("Vérification des contenus…", flush=True)
|
||||
print("Vérification des fiches…", flush=True)
|
||||
for path in sorted(CONTENT_DIR.rglob("*.md")):
|
||||
metadata = read_front_matter(path, validator)
|
||||
if metadata is None:
|
||||
relative_path = path.relative_to(CONTENT_DIR)
|
||||
if path.name == "_index.md" or len(relative_path.parts) == 1:
|
||||
continue
|
||||
|
||||
structure = check_structure(path, metadata, validator)
|
||||
if structure is None:
|
||||
section = relative_path.parts[0]
|
||||
if section not in CONTENT_SECTIONS:
|
||||
validator.error(path, f"section inconnue : {section}")
|
||||
continue
|
||||
|
||||
section, is_draft = structure
|
||||
expected_mzl = filename_mzl(path, validator) if section == "signes" else None
|
||||
if is_draft:
|
||||
result = read_front_matter(path, validator)
|
||||
if result is None:
|
||||
continue
|
||||
metadata, front_matter = result
|
||||
|
||||
check_common_fields(metadata, front_matter, path, validator)
|
||||
|
||||
if section == "vocabulaire":
|
||||
check_vocabulary(path, metadata, validator)
|
||||
elif section == "signes":
|
||||
check_sign(path, metadata, expected_mzl, validator)
|
||||
check_vocabulary(metadata, path, validator)
|
||||
else:
|
||||
for table_name, fields in SECTION_FIELDS[section].items():
|
||||
check_fields(metadata, table_name, fields, path, validator)
|
||||
|
||||
return validator.finish()
|
||||
|
||||
|
||||
+81
-135
@@ -10,14 +10,14 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from _schema import SECTION_ALIASES, VOCABULARY_FIELDS
|
||||
from _schema import LIST_FIELDS, SECTION_ALIASES, SECTION_FIELDS, VOCABULARY_FIELDS
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
CONTENT_DIR = ROOT_DIR / "src" / "content"
|
||||
TIMEZONE_NAME = "Europe/Paris"
|
||||
|
||||
MZL_RE = re.compile(r"(?:mzl-)?([0-9]{1,3})")
|
||||
|
||||
FieldValue = str | list[str]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -48,55 +48,33 @@ def normalize(value: str) -> str:
|
||||
return unicodedata.normalize("NFC", value.strip())
|
||||
|
||||
|
||||
def toml_string(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
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 validate_identifier(identifier: str, *, allow_leading_hyphen: bool = False) -> str:
|
||||
"""Valide un identifiant déjà écrit sous sa forme de nom de fichier."""
|
||||
def validate_identifier(identifier: str, allow_leading_hyphen: bool) -> str:
|
||||
value = normalize(identifier)
|
||||
if value.startswith("-") and not allow_leading_hyphen:
|
||||
raise ValueError("seul le vocabulaire accepte un identifiant commençant par un tiret")
|
||||
|
||||
body = value[1:] if value.startswith("-") else value
|
||||
if not body or value in {".", "..", "_index"}:
|
||||
|
||||
if value.startswith("-") and not allow_leading_hyphen:
|
||||
raise ValueError(
|
||||
"seul le vocabulaire accepte un identifiant commençant par un tiret"
|
||||
)
|
||||
if (
|
||||
not body
|
||||
or value in {".", "..", "_index"}
|
||||
or value != value.lower()
|
||||
or value.endswith("-")
|
||||
or "--" in value
|
||||
or not all(character.isalnum() or character == "-" for character in body)
|
||||
):
|
||||
raise ValueError("identifiant de fichier invalide")
|
||||
if value != value.lower():
|
||||
raise ValueError("l’identifiant doit être écrit en minuscules")
|
||||
if value.endswith("-") or "--" in value:
|
||||
raise ValueError(
|
||||
"l’identifiant ne doit pas finir par un tiret ni en contenir deux de suite"
|
||||
)
|
||||
if not all(character.isalnum() or character == "-" for character in body):
|
||||
raise ValueError(
|
||||
"l’identifiant ne doit contenir que des lettres, des chiffres et des tirets"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def parse_mzl(identifier: str) -> tuple[str, str]:
|
||||
value = normalize(identifier)
|
||||
match = MZL_RE.fullmatch(value)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
"un signe doit être identifié par un numéro MZL de un à trois chiffres ASCII"
|
||||
)
|
||||
|
||||
number = int(match.group(1))
|
||||
if not 1 <= number <= 999:
|
||||
match = MZL_RE.fullmatch(normalize(identifier))
|
||||
if match is None or not 1 <= int(match.group(1)) <= 999:
|
||||
raise ValueError("le numéro MZL doit être compris entre 1 et 999")
|
||||
|
||||
mzl = f"{number:03d}"
|
||||
mzl = f"{int(match.group(1)):03d}"
|
||||
return f"mzl-{mzl}", mzl
|
||||
|
||||
|
||||
@@ -126,88 +104,55 @@ def title_from_identifier(identifier: str) -> str:
|
||||
return title[:1].upper() + title[1:]
|
||||
|
||||
|
||||
def build_vocabulary(title: str, timestamp: str, nature: str) -> str:
|
||||
fields = "\n".join(
|
||||
f"{field} = []" if field == "logograms" else f'{field} = ""'
|
||||
for field in VOCABULARY_FIELDS[nature]
|
||||
def empty_fields(fields: tuple[str, ...]) -> dict[str, FieldValue]:
|
||||
return {field: [] if field in LIST_FIELDS else "" for field in fields}
|
||||
|
||||
|
||||
def content_tables(
|
||||
section: str,
|
||||
nature: str | None = None,
|
||||
mzl: str | None = None,
|
||||
) -> dict[str, dict[str, FieldValue]]:
|
||||
if section == "vocabulaire":
|
||||
assert nature is not None
|
||||
return {
|
||||
"extra": empty_fields(VOCABULARY_FIELDS[nature]),
|
||||
"taxonomies": {"natures": [nature]},
|
||||
}
|
||||
|
||||
tables = {
|
||||
table: empty_fields(fields)
|
||||
for table, fields in SECTION_FIELDS[section].items()
|
||||
}
|
||||
if section == "signes":
|
||||
assert mzl is not None
|
||||
tables["extra"]["mzl"] = mzl
|
||||
return tables
|
||||
|
||||
|
||||
def build_content(
|
||||
title: str,
|
||||
timestamp: str,
|
||||
tables: dict[str, dict[str, FieldValue]],
|
||||
) -> str:
|
||||
lines = [
|
||||
"+++",
|
||||
f"title = {json.dumps(title, ensure_ascii=False)}",
|
||||
f"date = {timestamp}",
|
||||
"# updated = YYYY-MM-DDTHH:MM:SS+HH:MM",
|
||||
'description = ""',
|
||||
"draft = true",
|
||||
]
|
||||
|
||||
for table, fields in tables.items():
|
||||
lines.extend(("", f"[{table}]"))
|
||||
lines.extend(
|
||||
f"{field} = {json.dumps(value, ensure_ascii=False)}"
|
||||
for field, value in fields.items()
|
||||
)
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[extra]
|
||||
{fields}
|
||||
|
||||
[taxonomies]
|
||||
natures = [{toml_string(nature)}]
|
||||
+++
|
||||
"""
|
||||
|
||||
|
||||
def build_sign(title: str, timestamp: str, mzl: str) -> str:
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[extra]
|
||||
sign = ""
|
||||
mzl = "{mzl}"
|
||||
|
||||
[taxonomies]
|
||||
lectures = []
|
||||
+++
|
||||
"""
|
||||
|
||||
|
||||
def build_page(section: str, title: str, timestamp: str) -> str:
|
||||
if section == "articles":
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[extra]
|
||||
banner = ""
|
||||
banner_alt = ""
|
||||
banner_credit = ""
|
||||
+++
|
||||
"""
|
||||
|
||||
if section == "grammaire":
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[taxonomies]
|
||||
themes = []
|
||||
+++
|
||||
"""
|
||||
|
||||
if section == "textes":
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[taxonomies]
|
||||
genres = []
|
||||
+++
|
||||
"""
|
||||
|
||||
raise ValueError(f"section non prise en charge : {section}")
|
||||
lines.extend(("+++", ""))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -218,13 +163,13 @@ def main() -> int:
|
||||
if section != "vocabulaire" and args.nature is not None:
|
||||
raise ValueError("--nature est réservé aux fiches de vocabulaire")
|
||||
|
||||
timestamp = current_timestamp()
|
||||
custom_title = normalize(args.title) if args.title is not None else None
|
||||
nature = None
|
||||
mzl = None
|
||||
|
||||
if section == "signes":
|
||||
filename, mzl = parse_mzl(args.identifiant)
|
||||
title = custom_title if custom_title is not None else ""
|
||||
content = build_sign(title, timestamp, mzl)
|
||||
else:
|
||||
filename = validate_identifier(
|
||||
args.identifiant,
|
||||
@@ -233,21 +178,27 @@ def main() -> int:
|
||||
if section == "vocabulaire":
|
||||
nature = choose_nature(args.nature)
|
||||
title = custom_title if custom_title is not None else filename
|
||||
content = build_vocabulary(title, timestamp, nature)
|
||||
else:
|
||||
title = (
|
||||
custom_title
|
||||
if custom_title is not None
|
||||
else title_from_identifier(filename)
|
||||
)
|
||||
content = build_page(section, title, timestamp)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
|
||||
timestamp = datetime.now(ZoneInfo("Europe/Paris")).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
content = build_content(
|
||||
title,
|
||||
timestamp,
|
||||
content_tables(section, nature, mzl),
|
||||
)
|
||||
except (ValueError, ZoneInfoNotFoundError) as exc:
|
||||
print(f"Erreur : {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
destination = CONTENT_DIR / section / f"{filename}.md"
|
||||
try:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with destination.open("x", encoding="utf-8") as stream:
|
||||
stream.write(content)
|
||||
except FileExistsError:
|
||||
@@ -257,15 +208,10 @@ def main() -> int:
|
||||
)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"Erreur : impossible de créer {destination.relative_to(ROOT_DIR)} ({exc})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"Erreur : impossible de créer la fiche ({exc})", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Brouillon créé : {destination.relative_to(ROOT_DIR)}")
|
||||
print("Compléter la fiche, passer draft à false, puis lancer :")
|
||||
print(" ./scripts/check-content.py")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from _schema import CONTENT_SECTIONS, UPDATED_LINE_RE
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CONTENT = (ROOT / "src" / "content").resolve()
|
||||
|
||||
@@ -15,7 +17,6 @@ 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:
|
||||
@@ -39,8 +40,14 @@ def resolve_path(value: str) -> Path:
|
||||
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")
|
||||
relative_path = path.relative_to(CONTENT)
|
||||
if (
|
||||
path.suffix != ".md"
|
||||
or path.name == "_index.md"
|
||||
or len(relative_path.parts) == 1
|
||||
or relative_path.parts[0] not in CONTENT_SECTIONS
|
||||
):
|
||||
raise ValueError("le chemin doit désigner une fiche d’une section connue")
|
||||
|
||||
return path
|
||||
|
||||
@@ -52,7 +59,7 @@ def update_timestamp(path: Path) -> str:
|
||||
if front_matter is None:
|
||||
raise ValueError("front matter TOML délimité par +++ absent")
|
||||
|
||||
matches = list(UPDATED_RE.finditer(front_matter.group("body")))
|
||||
matches = list(UPDATED_LINE_RE.finditer(front_matter.group("body")))
|
||||
if len(matches) != 1:
|
||||
raise ValueError("la fiche doit contenir exactement une ligne updated")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user