#!/usr/bin/env python3 """Vérifie que chaque fiche contient les champs prévus pour son modèle.""" import sys import tomllib from datetime import datetime from pathlib import Path from typing import Any from _schema import ( CONTENT_SECTIONS, LIST_FIELDS, SECTION_FIELDS, UPDATED_LINE_RE, URL_TERM_FIELDS, 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, } 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_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: if check_string_list(table[field], label, path, validator) and field in URL_TERM_FIELDS: spaced_terms = [ term for term in table[field] if any(char.isspace() for char in term) ] if spaced_terms: validator.error( path, f"{label} doit utiliser des tirets à la place des espaces : " + ", ".join(repr(term) for term in spaced_terms), ) 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 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())