297 lines
8.7 KiB
Python
Executable File
297 lines
8.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Vérifie la structure des contenus, le vocabulaire et les signes."""
|
|
|
|
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
|
|
|
|
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}")
|
|
|
|
|
|
class Validator:
|
|
def __init__(self) -> None:
|
|
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()} : "
|
|
print(f"ERREUR : {label}{message}", file=sys.stderr)
|
|
self.errors += 1
|
|
|
|
def finish(self) -> int:
|
|
print(f"\nRésultat : {self.errors} erreur(s).")
|
|
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:
|
|
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:
|
|
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
|
|
|
|
try:
|
|
closing_index = lines.index("+++", 1)
|
|
except ValueError:
|
|
validator.error(path, "délimiteur final +++ absent")
|
|
return None
|
|
|
|
try:
|
|
return tomllib.loads("\n".join(lines[1:closing_index]))
|
|
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,
|
|
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
|
|
|
|
|
|
def require_string(
|
|
metadata: dict[str, Any],
|
|
key: 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:
|
|
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",
|
|
)
|
|
|
|
|
|
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(
|
|
metadata: dict[str, Any],
|
|
path: Path,
|
|
validator: Validator,
|
|
) -> str | None:
|
|
taxonomies = require_table(metadata, "taxonomies", path, validator)
|
|
if taxonomies is None:
|
|
return None
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
def check_vocabulary(
|
|
path: Path,
|
|
metadata: dict[str, Any],
|
|
validator: Validator,
|
|
) -> None:
|
|
nature = vocabulary_nature(metadata, path, validator)
|
|
extra = require_table(metadata, "extra", path, validator)
|
|
if nature is None or extra is None:
|
|
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:
|
|
return
|
|
|
|
require_string(extra, "sign", path, validator)
|
|
|
|
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")
|
|
return
|
|
if int(declared_mzl) == 0:
|
|
validator.error(path, "extra.mzl doit être compris entre 001 et 999")
|
|
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}',
|
|
)
|
|
|
|
|
|
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)
|
|
for path in sorted(CONTENT_DIR.rglob("*.md")):
|
|
metadata = read_front_matter(path, validator)
|
|
if metadata is None:
|
|
continue
|
|
|
|
structure = check_structure(path, metadata, validator)
|
|
if structure is None:
|
|
continue
|
|
|
|
section, is_draft = structure
|
|
expected_mzl = filename_mzl(path, validator) if section == "signes" else None
|
|
if is_draft:
|
|
continue
|
|
|
|
if section == "vocabulaire":
|
|
check_vocabulary(path, metadata, validator)
|
|
elif section == "signes":
|
|
check_sign(path, metadata, expected_mzl, validator)
|
|
|
|
return validator.finish()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|