#!/usr/bin/env python3 """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 ( CONTENT_SECTIONS, LIST_FIELDS, SECTION_FIELDS, UPDATED_LINE_RE, VOCABULARY_FIELDS, ) ROOT_DIR = Path(__file__).resolve().parent.parent CONTENT_DIR = ROOT_DIR / "src" / "content" COMMON_FIELDS = { "title": str, "date": datetime, "description": str, "draft": bool, } MZL_FILENAME_RE = re.compile(r"mzl-([0-9]{3})") class Validator: def __init__(self) -> None: self.errors = 0 def error(self, path: Path | None, message: str) -> None: label = f"{path.relative_to(ROOT_DIR).as_posix()} : " if path else "" 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 read_front_matter( path: Path, validator: Validator, ) -> tuple[dict[str, Any], str] | None: try: lines = path.read_text(encoding="utf-8").splitlines() except (OSError, UnicodeError) as exc: validator.error(path, f"lecture impossible ({exc})") return None 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 source = "\n".join(lines[1:closing_index]) try: return tomllib.loads(source), source except tomllib.TOMLDecodeError as exc: validator.error(path, f"front matter TOML invalide ({exc})") return None def check_filename( path: Path, section: str, validator: Validator, ) -> None: stem = path.stem normalized = unicodedata.normalize("NFC", stem) if stem != normalized: validator.error(path, "nom de fichier non normalisé en Unicode NFC") if section == "signes": match = MZL_FILENAME_RE.fullmatch(stem) if match is None or not 1 <= int(match.group(1)) <= 999: validator.error(path, "un signe doit être nommé mzl-NNN.md (001 à 999)") return allow_leading_hyphen = section == "vocabulaire" body = stem[1:] if stem.startswith("-") else stem if stem.startswith("-") and not allow_leading_hyphen: validator.error( path, "seul le vocabulaire accepte un nom commençant par un tiret", ) return if ( not body or stem in {".", "..", "_index"} or stem != stem.lower() or stem.endswith("-") or "--" in stem or not all(character.isalnum() or character == "-" for character in body) ): validator.error( path, "nom de fichier invalide : utiliser des lettres Unicode minuscules, " "des chiffres et des tirets", ) def check_type( value: Any, expected_type: type, label: str, path: Path, validator: Validator, ) -> bool: if isinstance(value, expected_type): return True validator.error(path, f"{label} doit être de type {expected_type.__name__}") return False 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], table_name: str, fields: tuple[str, ...], path: Path, validator: Validator, ) -> None: table = metadata.get(table_name) if not isinstance(table, dict): validator.error(path, f"{table_name} doit être une table TOML") return 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_common_fields( metadata: dict[str, Any], front_matter: str, path: Path, validator: Validator, ) -> 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) updated_lines = len(UPDATED_LINE_RE.findall(front_matter)) if updated_lines != 1: validator.error(path, "la fiche doit contenir exactement une ligne updated") if "updated" in metadata: check_type(metadata["updated"], datetime, "updated", path, validator) def check_vocabulary( metadata: dict[str, Any], path: Path, validator: Validator, ) -> None: taxonomies = metadata.get("taxonomies") if not isinstance(taxonomies, dict): validator.error(path, "taxonomies doit être une table TOML") return if "natures" not in taxonomies: validator.error(path, "champ taxonomies manquant : natures") return natures = taxonomies["natures"] if not check_string_list(natures, "taxonomies.natures", path, validator): return if len(natures) != 1: validator.error(path, "taxonomies.natures doit contenir exactement une nature") return nature = natures[0] if nature not in VOCABULARY_FIELDS: validator.error(path, f"nature grammaticale inconnue : {nature!r}") return 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 fiches…", flush=True) for path in sorted(CONTENT_DIR.rglob("*.md")): relative_path = path.relative_to(CONTENT_DIR) if path.name == "_index.md" or len(relative_path.parts) == 1: continue section = relative_path.parts[0] if section not in CONTENT_SECTIONS: validator.error(path, f"section inconnue : {section}") continue check_filename(path, section, validator) 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(metadata, path, validator) else: for table_name, fields in SECTION_FIELDS[section].items(): check_fields(metadata, table_name, fields, path, validator) return validator.finish() if __name__ == "__main__": raise SystemExit(main())