Files
lescarnetsdakkadien.netig.net/scripts/check-content.py
T

222 lines
6.3 KiB
Python
Executable File

#!/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())